diff --git a/Cargo.lock b/Cargo.lock index ad65136..54bfbaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,40 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -551,6 +585,7 @@ dependencies = [ "include_dir", "matrix-sdk", "names", + "nostr", "regex", "reqwest", "rusqlite", @@ -1598,6 +1633,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2023,6 +2073,18 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2449,6 +2511,30 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3503,12 +3589,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/Cargo.toml b/Cargo.toml index 8d878be..b4fe771 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,4 +108,9 @@ strum = { version = "0.28.0", features = ["derive"] } tokio = "1.52.1" # https://github.com/fnichol/names/blob/main/CHANGELOG.md -names = "0.14.0" \ No newline at end of file +names = "0.14.0" + +# Nostr protocol types + Schnorr signature verification, used by the NIP-98 +# HTTP auth extractor +# https://github.com/rust-nostr/nostr/releases +nostr = { version = "0.44.2", default-features = false, features = ["std"] } diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 46942a4..593a9ea 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod error; +pub mod nostr_auth; pub mod v2; pub mod v3; pub mod v4; diff --git a/src/rest/nostr_auth.rs b/src/rest/nostr_auth.rs new file mode 100644 index 0000000..4597883 --- /dev/null +++ b/src/rest/nostr_auth.rs @@ -0,0 +1,254 @@ +// See the note in `src/service/nip98.rs` — this extractor is infrastructure +// ahead of the endpoints that will consume it in a follow-up PR. +#![allow(dead_code)] + +use actix_web::web::Data; +use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest}; +use std::future::Future; +use std::pin::Pin; + +use crate::service::nip98; + +/// Trusted external base URL of the API (e.g. `https://api.btcmap.org`, or +/// `http://localhost:8000` in dev). Must be injected via `app_data` by +/// `main.rs`. The extractor uses this, not the `Host`/`X-Forwarded-*` +/// headers, to reconstruct the URL the signed NIP-98 event is expected to +/// bind to. Trusting the request headers here would allow an attacker who +/// had tricked a user into signing a bogus-host URL to replay the event +/// against the real server by spoofing the `Host` header. +#[derive(Clone)] +pub struct ApiBaseUrl(pub String); + +/// NIP-98 extractor. Mirrors the `Auth` bearer extractor: never fails the +/// request, always yields a struct. When `npub` is `None` the handler +/// decides whether to reject (401) or treat auth as optional. +/// +/// A `Some(npub)` value guarantees the event was: +/// - base64-decoded + JSON-parsed successfully +/// - kind 27235 with an in-window `created_at` +/// - signed such that the `u` tag matches the actual request URL +/// - signed such that the `method` tag matches the actual request method +/// - verified under a valid Schnorr signature +/// +/// The `npub` is bech32-encoded (`npub1...`), matching the encoding used by +/// the `user.npub` DB column. +pub struct NostrAuth { + pub npub: Option, +} + +impl FromRequest for NostrAuth { + type Error = actix_web::Error; + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + let req = req.clone(); + let base_url = req.app_data::>().cloned(); + Box::pin(async move { + // Without a trusted base URL we can't safely verify the `u` tag, + // so refuse to attempt verification: yield `npub: None` and let + // the handler decide whether to reject (matches the `Auth` + // extractor's pattern when state is missing). Whether this ends + // up fail-closed or fail-open in practice depends on the + // handler — required-auth handlers must treat `None` as 401. + let Some(base_url) = base_url else { + return Ok(NostrAuth { npub: None }); + }; + + let Some(payload) = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(nip98::extract_nostr_auth) + else { + return Ok(NostrAuth { npub: None }); + }; + + // Base URL comes from config, never from request headers — path + // and query are the only request-derived pieces. An attacker who + // spoofs `Host` or `X-Forwarded-*` cannot influence what the + // signature is checked against. + let path_and_query = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or(req.uri().path()); + let full_url = format!("{}{}", base_url.0.trim_end_matches('/'), path_and_query); + let method = req.method().as_str(); + + match nip98::verify(payload, &full_url, method) { + Ok(event) => Ok(NostrAuth { + npub: Some(event.npub), + }), + Err(e) => { + tracing::debug!( + error = %e, + url = %full_url, + method = %method, + "NIP-98 verification failed" + ); + Ok(NostrAuth { npub: None }) + } + } + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use actix_web::test::TestRequest; + use actix_web::{test, web, App, HttpResponse}; + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine; + use nostr::event::EventBuilder; + use nostr::key::Keys; + use nostr::nips::nip19::ToBech32; + use nostr::{JsonUtil, Kind, Tag, Timestamp}; + + const TEST_BASE: &str = "https://api.example/test"; + + async fn handler(auth: NostrAuth) -> HttpResponse { + match auth.npub { + Some(npub) => HttpResponse::Ok().body(npub), + None => HttpResponse::Unauthorized().finish(), + } + } + + fn app_with_base() -> App< + impl actix_web::dev::ServiceFactory< + actix_web::dev::ServiceRequest, + Config = (), + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, + > { + App::new() + .app_data(Data::new(ApiBaseUrl(TEST_BASE.to_string()))) + .route("/auth", web::post().to(handler)) + .route("/auth", web::get().to(handler)) + .route("/", web::post().to(handler)) + } + + fn signed_nip98(keys: &Keys, url: &str, method: &str) -> String { + let event = EventBuilder::new(Kind::from_u16(27235), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + BASE64.encode(event.as_json().as_bytes()) + } + + #[actix_web::test] + async fn missing_header_yields_none() { + let app = test::init_service(app_with_base()).await; + let req = TestRequest::post().uri("/").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } + + #[actix_web::test] + async fn bearer_header_yields_none() { + let app = test::init_service(app_with_base()).await; + let req = TestRequest::post() + .uri("/") + .insert_header((header::AUTHORIZATION, "Bearer something")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } + + #[actix_web::test] + async fn valid_header_yields_bech32_npub() { + let keys = Keys::generate(); + let app = test::init_service(app_with_base()).await; + let signed = signed_nip98(&keys, &format!("{TEST_BASE}/auth"), "POST"); + let req = TestRequest::post() + .uri("/auth") + .insert_header((header::AUTHORIZATION, format!("Nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 200); + let body = test::read_body(res).await; + let npub = std::str::from_utf8(&body).unwrap(); + assert_eq!(npub, keys.public_key().to_bech32().unwrap()); + } + + #[actix_web::test] + async fn lowercase_scheme_accepted() { + let keys = Keys::generate(); + let app = test::init_service(app_with_base()).await; + let signed = signed_nip98(&keys, &format!("{TEST_BASE}/auth"), "POST"); + let req = TestRequest::post() + .uri("/auth") + .insert_header((header::AUTHORIZATION, format!("nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 200); + } + + #[actix_web::test] + async fn url_mismatch_yields_none() { + let keys = Keys::generate(); + let app = test::init_service(app_with_base()).await; + // Event signs a different path than the request is for. + let signed = signed_nip98(&keys, &format!("{TEST_BASE}/different"), "POST"); + let req = TestRequest::post() + .uri("/auth") + .insert_header((header::AUTHORIZATION, format!("Nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } + + #[actix_web::test] + async fn method_derived_from_request() { + let keys = Keys::generate(); + let app = test::init_service(app_with_base()).await; + // Event signs POST, request is GET — should fail. + let signed = signed_nip98(&keys, &format!("{TEST_BASE}/auth"), "POST"); + let req = TestRequest::get() + .uri("/auth") + .insert_header((header::AUTHORIZATION, format!("Nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } + + #[actix_web::test] + async fn spoofed_host_header_is_ignored() { + // Attacker signs an event for "http://evil.example/auth" and replays + // it to the real server, sending a spoofed Host header. Because the + // extractor pins the base URL from app_data (not from connection + // info), the `u` tag in the event will not match, and auth must fail. + let keys = Keys::generate(); + let app = test::init_service(app_with_base()).await; + let signed = signed_nip98(&keys, "http://evil.example/auth", "POST"); + let req = TestRequest::post() + .uri("/auth") + .insert_header((header::HOST, "evil.example")) + .insert_header((header::AUTHORIZATION, format!("Nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } + + #[actix_web::test] + async fn no_base_url_configured_yields_none() { + let keys = Keys::generate(); + // Intentionally omit Data::new(ApiBaseUrl(...)). Auth must fail + // closed: if main.rs forgets to inject the config, we do not fall + // back to reconstructing from headers. + let app = test::init_service(App::new().route("/auth", web::post().to(handler))).await; + let signed = signed_nip98(&keys, "https://api.example/test/auth", "POST"); + let req = TestRequest::post() + .uri("/auth") + .insert_header((header::AUTHORIZATION, format!("Nostr {signed}"))) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), 401); + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index ba76a22..ab728ed 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -8,6 +8,7 @@ pub mod gitea; pub mod invoice; pub mod log; pub mod matrix; +pub mod nip98; pub mod og; pub mod osm; pub mod overpass; diff --git a/src/service/nip98.rs b/src/service/nip98.rs new file mode 100644 index 0000000..d3bc96d --- /dev/null +++ b/src/service/nip98.rs @@ -0,0 +1,416 @@ +//! NIP-98 HTTP auth event verification. +//! +//! ## Intentional limitation: no request-body binding +//! +//! NIP-98 specifies an optional `payload` tag whose value is the SHA256 hash +//! of the HTTP request body. When present it binds the signature to the body +//! content, preventing an attacker who captures a signed event from replaying +//! it with a different body inside the ±60s recency window. +//! +//! **This module does not verify the `payload` tag.** The follow-up endpoint +//! it will be used from (`POST /v4/auth/nostr`) carries no request body — +//! the signed event travels entirely in the `Authorization` header. For that +//! endpoint a `payload` check would compute `SHA256("")` on every call and +//! add no security. +//! +//! Future endpoints with request bodies (e.g. `PUT /v4/users/me/nostr`) +//! **must** add body-hash verification. Wiring that in after the fact +//! requires either a second extractor variant that consumes `web::Bytes` +//! (so the handler cannot also read the body) or a middleware that buffers +//! and replays. See https://github.com/nostr-protocol/nips/blob/master/98.md +//! for the spec text. + +// Lands in isolation ahead of the endpoints that will consume it. The next +// PR wires `NostrAuth` into `POST /v4/auth/nostr`; until then clippy sees +// these items as dead. +#![allow(dead_code)] + +use crate::error::Error; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use nostr::event::Event; +use nostr::nips::nip19::ToBech32; +use nostr::JsonUtil; +use nostr::Kind; +use std::time::{SystemTime, UNIX_EPOCH}; + +const NIP98_KIND: u16 = 27235; +const MAX_TIMESTAMP_DRIFT_SECS: u64 = 60; + +/// Result of a successfully verified NIP-98 event. +#[derive(Debug)] +pub struct VerifiedNip98Event { + /// Bech32-encoded (`npub1...`) Nostr public key, per NIP-19. Matches the + /// encoding used by the `user.npub` column (see `select_by_npub` tests). + pub npub: String, +} + +/// Verify a NIP-98 HTTP auth event. +/// +/// `authorization_payload` is the base64-encoded event string (the part after +/// "Nostr " in the Authorization header). +/// +/// Validates: +/// 1. Base64 decoding and JSON parsing +/// 2. `kind == 27235` +/// 3. `created_at` within 60 seconds of server time +/// 4. `u` tag matches `expected_url` +/// 5. `method` tag matches `expected_method` (case-sensitive, per RFC 9110 §9.1) +/// 6. Schnorr signature is valid +pub fn verify( + authorization_payload: &str, + expected_url: &str, + expected_method: &str, +) -> Result { + let decoded = BASE64 + .decode(authorization_payload) + .map_err(|e| Error::Other(format!("Invalid base64: {e}")))?; + + let json_str = + String::from_utf8(decoded).map_err(|e| Error::Other(format!("Invalid UTF-8: {e}")))?; + + let event = Event::from_json(&json_str) + .map_err(|e| Error::Other(format!("Invalid Nostr event JSON: {e}")))?; + + if event.kind != Kind::from_u16(NIP98_KIND) { + return Err(Error::Other(format!( + "Invalid event kind: expected {NIP98_KIND}, got {}", + event.kind.as_u16() + ))); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::Other(format!("System time error: {e}")))? + .as_secs(); + let event_time = event.created_at.as_secs(); + let drift = now.abs_diff(event_time); + if drift > MAX_TIMESTAMP_DRIFT_SECS { + return Err(Error::Other(format!( + "Event timestamp too far from server time (drift: {drift}s, max: {MAX_TIMESTAMP_DRIFT_SECS}s)" + ))); + } + + let u_tag = require_unique_tag(&event, "u")?; + if u_tag != expected_url { + return Err(Error::Other(format!( + "URL mismatch: event has '{u_tag}', expected '{expected_url}'" + ))); + } + + // RFC 9110 §9.1 makes HTTP method tokens case-sensitive, and NIP-98 + // requires the tag value be the same HTTP method as the request. All + // standard methods are uppercase, so a strict comparison costs nothing + // and matches what every real client sends. + let method_tag = require_unique_tag(&event, "method")?; + if method_tag != expected_method { + return Err(Error::Other(format!( + "Method mismatch: event has '{method_tag}', expected '{expected_method}'" + ))); + } + + event + .verify() + .map_err(|e| Error::Other(format!("Signature verification failed: {e}")))?; + + let npub = event + .pubkey + .to_bech32() + .map_err(|e| Error::Other(format!("Pubkey bech32 encoding failed: {e}")))?; + + Ok(VerifiedNip98Event { npub }) +} + +/// Extract the base64 payload from an `Authorization` header value. Matches +/// the `Nostr` scheme case-insensitively per RFC 9110. Tolerates multiple +/// whitespace characters between scheme and credentials, as `1*SP` in the +/// `credentials` ABNF permits. Rejects headers with extra trailing tokens. +pub fn extract_nostr_auth(authorization_header: &str) -> Option<&str> { + let mut parts = authorization_header.split_whitespace(); + let scheme = parts.next()?; + let payload = parts.next()?; + // `token68` in the ABNF contains no whitespace — any further tokens mean + // the header is malformed. + if parts.next().is_some() { + return None; + } + if scheme.eq_ignore_ascii_case("Nostr") { + Some(payload) + } else { + None + } +} + +/// Look up a NIP-98 tag, rejecting both absence and duplication. +/// +/// NIP-98 doesn't explicitly say "exactly one", but accepting duplicates +/// would let a malicious frontend trick a user into signing an event with +/// e.g. two `u` tags — one matching, one bogus — and have the verifier +/// accept it via the matching one. The signature covers all tags so an +/// attacker can't add tags after the fact, but nothing stops a hostile +/// client from constructing such an event in the first place. +fn require_unique_tag(event: &Event, tag_name: &str) -> Result { + let mut found: Option = None; + for tag in event.tags.iter() { + let tag_vec = tag.as_slice(); + if tag_vec.len() >= 2 && tag_vec[0] == tag_name { + if found.is_some() { + return Err(Error::Other(format!( + "Duplicate '{tag_name}' tag in NIP-98 event" + ))); + } + found = Some(tag_vec[1].to_string()); + } + } + found.ok_or_else(|| Error::Other(format!("Missing '{tag_name}' tag in NIP-98 event"))) +} + +#[cfg(test)] +mod test { + use super::*; + use nostr::event::EventBuilder; + use nostr::key::Keys; + use nostr::Tag; + use nostr::Timestamp; + + fn make_nip98_event(keys: &Keys, url: &str, method: &str) -> String { + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + let json = event.as_json(); + BASE64.encode(json.as_bytes()) + } + + #[test] + fn valid_event_returns_bech32_npub() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let b64 = make_nip98_event(&keys, url, "POST"); + let result = verify(&b64, url, "POST").unwrap(); + assert!( + result.npub.starts_with("npub1"), + "npub should be bech32-encoded, got {}", + result.npub + ); + assert_eq!(result.npub, keys.public_key().to_bech32().unwrap()); + } + + #[test] + fn method_match_is_case_sensitive() { + // RFC 9110 §9.1: HTTP method tokens are case-sensitive. Lowercase + // 'post' must not match 'POST'. + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let b64 = make_nip98_event(&keys, url, "post"); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!(err.to_string().contains("Method mismatch")); + } + + #[test] + fn wrong_url() { + let keys = Keys::generate(); + let b64 = make_nip98_event(&keys, "https://example.com/wrong", "POST"); + let err = verify(&b64, "https://api.btcmap.org/v4/auth/nostr", "POST").unwrap_err(); + assert!(err.to_string().contains("URL mismatch")); + } + + #[test] + fn wrong_method() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let b64 = make_nip98_event(&keys, url, "GET"); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!(err.to_string().contains("Method mismatch")); + } + + #[test] + fn expired_event() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .custom_created_at(Timestamp::from(0)) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!(err.to_string().contains("timestamp too far")); + } + + #[test] + fn wrong_kind() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(1), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!(err.to_string().contains("Invalid event kind")); + } + + #[test] + fn invalid_base64() { + let err = verify("not-valid-base64!!!", "https://example.com", "POST").unwrap_err(); + assert!(err.to_string().contains("Invalid base64")); + } + + #[test] + fn tampered_signature_rejected() { + // Build a valid event, then flip one hex nibble of its signature so + // the structure and tags remain valid but the Schnorr check fails. + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + + // Flip one character of the sig hex. The event's `id` still matches + // the content, the tags are valid, only the signature is wrong. + let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); + let sig = json["sig"].as_str().unwrap().to_string(); + let first = sig.chars().next().unwrap(); + let flipped = if first == '0' { '1' } else { '0' }; + let mut new_sig = flipped.to_string(); + new_sig.push_str(&sig[1..]); + json["sig"] = serde_json::Value::String(new_sig); + let tampered_json = serde_json::to_string(&json).unwrap(); + let b64 = BASE64.encode(tampered_json.as_bytes()); + + let err = verify(&b64, url, "POST").unwrap_err(); + let msg = err.to_string(); + // Only `sig` is mutated. The Nostr event id (sha256 of pubkey, + // created_at, kind, tags, content) does not include the signature, + // so `event.verify()`'s id check still passes — the failure must + // come from the Schnorr step specifically. + assert!( + msg.contains("Signature verification failed"), + "expected Schnorr rejection, got: {msg}" + ); + } + + #[test] + fn missing_u_tag_rejected() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![Tag::parse(["method", "POST"]).unwrap()]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, "https://api.btcmap.org/v4/auth/nostr", "POST").unwrap_err(); + assert!(err.to_string().contains("Missing 'u' tag"), "got: {err}"); + } + + #[test] + fn missing_method_tag_rejected() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![Tag::parse(["u", url]).unwrap()]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!( + err.to_string().contains("Missing 'method' tag"), + "got: {err}" + ); + } + + #[test] + fn duplicate_u_tag_rejected() { + // Defense in depth: a hostile client could construct an event with + // two `u` tags — one real, one decoy. The signature covers both, so + // the user signs both. Reject outright. + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["u", "https://evil.example/auth"]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!(err.to_string().contains("Duplicate 'u' tag"), "got: {err}"); + } + + #[test] + fn duplicate_method_tag_rejected() { + let keys = Keys::generate(); + let url = "https://api.btcmap.org/v4/auth/nostr"; + let event = EventBuilder::new(Kind::from_u16(NIP98_KIND), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["method", "GET"]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let b64 = BASE64.encode(event.as_json().as_bytes()); + let err = verify(&b64, url, "POST").unwrap_err(); + assert!( + err.to_string().contains("Duplicate 'method' tag"), + "got: {err}" + ); + } + + #[test] + fn extract_nostr_auth_uppercase_scheme() { + let header = "Nostr eyJpZCI6IjEyMyJ9"; + assert_eq!(extract_nostr_auth(header), Some("eyJpZCI6IjEyMyJ9")); + } + + #[test] + fn extract_nostr_auth_lowercase_scheme() { + let header = "nostr eyJpZCI6IjEyMyJ9"; + assert_eq!(extract_nostr_auth(header), Some("eyJpZCI6IjEyMyJ9")); + } + + #[test] + fn extract_nostr_auth_rejects_bearer() { + assert_eq!(extract_nostr_auth("Bearer some-token"), None); + } + + #[test] + fn extract_nostr_auth_rejects_no_space() { + assert_eq!(extract_nostr_auth("Nostr"), None); + } + + #[test] + fn extract_nostr_auth_tolerates_multiple_spaces() { + // Per RFC 9110 §11.6.2: credentials = auth-scheme 1*SP (...) — one + // or more SP characters between scheme and credentials is legal. + assert_eq!(extract_nostr_auth("Nostr abc"), Some("abc")); + } + + #[test] + fn extract_nostr_auth_rejects_extra_tokens() { + // token68 contains no whitespace, so "Nostr abc extra" is malformed. + assert_eq!(extract_nostr_auth("Nostr abc extra"), None); + } +}