From fa612638b77aba4190d76857f6a8628f41590c49 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:20:42 +0200 Subject: [PATCH 1/6] feat(users): expose linked npub on whoami surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `npub` field (bech32, nullable) to the two "who am I" responses so the frontend can show whether the logged-in account has a Nostr identity linked: - REST `GET /v4/users/me` (MeResponse) — also flows into the create-token and update-username responses via the shared struct. - RPC `whoami` (Res) — kept in sync with the REST surface. `user.npub` already exists on the model (migration 98); this is a purely additive projection change, no query or schema change. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/rest/v4/users.md | 4 +++- src/rest/v4/users.rs | 45 ++++++++++++++++++++++++++++++++++++++++++ src/rpc/auth/whoami.rs | 26 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs/rest/v4/users.md b/docs/rest/v4/users.md index 54fb988..20a794b 100644 --- a/docs/rest/v4/users.md +++ b/docs/rest/v4/users.md @@ -36,7 +36,8 @@ curl https://api.btcmap.org/v4/users/me \ "name": "satoshi", "roles": ["user", "admin"], "saved_places": [{"id": 1, "name": "Bitcoin Cafe"}], - "saved_areas": [{"id": 2, "name": "Downtown District"}] + "saved_areas": [{"id": 2, "name": "Downtown District"}], + "npub": "npub1..." } ``` @@ -47,6 +48,7 @@ curl https://api.btcmap.org/v4/users/me \ | roles | Array | List of user roles (e.g., "user", "admin", "root") | | saved_places | Array | List of saved places with `id` and `name` fields | | saved_areas | Array | List of saved areas with `id` and `name` fields | +| npub | String \| null | Bech32 npub of the linked Nostr identity, or `null` if none is linked | ### Create User diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index 29f4a57..65e7749 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -41,6 +41,9 @@ pub struct MeResponse { pub roles: Vec, pub saved_places: Vec, pub saved_areas: Vec, + /// Bech32 npub (`npub1...`) of the Nostr identity linked to this user, + /// or `null` when no pubkey is linked. + pub npub: Option, } impl From<&User> for MeResponse { @@ -51,6 +54,7 @@ impl From<&User> for MeResponse { roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places: vec![], saved_areas: vec![], + npub: user.npub.clone(), } } } @@ -82,6 +86,7 @@ pub async fn me(auth: Auth, pool: Data) -> Result, Re roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places, saved_areas, + npub: user.npub, })) } @@ -257,6 +262,7 @@ pub async fn create_token( roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places, saved_areas, + npub: user.npub, }, })) } @@ -315,6 +321,45 @@ mod test { let res: MeResponse = test::call_and_read_body_json(&app, req).await; assert_eq!(res.id, user.id); assert_eq!(res.name, "test_user"); + // A password-only user has no linked Nostr identity. + assert_eq!(res.npub, None); + Ok(()) + } + + #[test] + async fn me_returns_linked_npub() -> Result<()> { + let pool = pool(); + let npub = "npub1example".to_string(); + let user = db::main::user::queries::insert_with_npub( + "nostr_user", + "", + &npub, + &[Role::User], + &pool, + ) + .await?; + let _token = db::main::access_token::queries::insert( + user.id, + "".into(), + "secret".into(), + vec![Role::Root], + &pool, + ) + .await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(me)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me") + .to_request(); + let res: MeResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub)); Ok(()) } diff --git a/src/rpc/auth/whoami.rs b/src/rpc/auth/whoami.rs index 3d478cc..774281a 100644 --- a/src/rpc/auth/whoami.rs +++ b/src/rpc/auth/whoami.rs @@ -8,6 +8,10 @@ use time::OffsetDateTime; pub struct Res { pub name: String, pub roles: Vec, + /// Bech32 npub (`npub1...`) of the Nostr identity linked to this user, + /// or `null` when no pubkey is linked. Mirrors the field on the REST + /// `GET /v4/users/me` response so both whoami surfaces stay in sync. + pub npub: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, } @@ -17,6 +21,7 @@ pub async fn run(user: &User) -> Result { Ok(Res { name: user.name.clone(), roles, + npub: user.npub.clone(), created_at: OffsetDateTime::parse(&user.created_at, &Rfc3339)?, }) } @@ -47,12 +52,33 @@ mod test { assert_eq!(result.name, "Test User"); assert_eq!(result.roles, vec!["admin".to_string(), "user".to_string()]); + assert_eq!(result.npub, None); assert_eq!( result.created_at, OffsetDateTime::parse("2023-01-01T00:00:00Z", &Rfc3339).unwrap() ); } + #[test] + async fn passes_through_linked_npub() { + let user = User { + id: 1, + name: "Nostr User".to_string(), + password: "".to_string(), + roles: vec![Role::User], + saved_places: vec![], + saved_areas: vec![], + npub: Some("npub1example".to_string()), + created_at: "2023-01-01T00:00:00Z".to_string(), + updated_at: "2023-01-01T00:00:00Z".to_string(), + deleted_at: None, + }; + + let result = super::run(&user).await.unwrap(); + + assert_eq!(result.npub, Some("npub1example".to_string())); + } + #[test] async fn empty_name() { // Test with empty name (should still work) From 8e3dbb3968010efd9ce486f87d00fbe6a455c2d1 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:22:54 +0200 Subject: [PATCH 2/6] feat(users): add GET and DELETE /v4/users/me/nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Bearer-authenticated sub-resources for an account's Nostr link: - GET /v4/users/me/nostr -> { npub } (or null) — lets a client poll just the link state without the full /me payload. - DELETE /v4/users/me/nostr -> clears the link via set_npub(None). Idempotent: returns 200 { npub: null } even when nothing was linked, since clearing your own link needs no NIP-98 proof. Registered in the v4 users scope. Removes the now-unused #[allow(dead_code)] on user::queries::set_npub. Linking/replacing a pubkey (PUT) lands separately since it additionally requires a NIP-98 ownership proof. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/db/main/user/queries.rs | 1 - src/main.rs | 2 + src/rest/v4/users.rs | 183 ++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/db/main/user/queries.rs b/src/db/main/user/queries.rs index 82102b1..f71bb34 100644 --- a/src/db/main/user/queries.rs +++ b/src/db/main/user/queries.rs @@ -106,7 +106,6 @@ pub async fn set_saved_areas(id: i64, saved_areas: &[i64], pool: &Pool) -> Resul .await? } -#[allow(dead_code)] pub async fn set_npub(id: i64, npub: Option, pool: &Pool) -> Result { pool.get() .await? diff --git a/src/main.rs b/src/main.rs index 7686c76..7d7e707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,6 +211,8 @@ async fn main() -> Result<()> { .service(rest::v4::users::post) .service(rest::v4::users::change_password) .service(rest::v4::users::update_username) + .service(rest::v4::users::get_nostr) + .service(rest::v4::users::delete_nostr) .service(rest::v4::users::create_token), ), ) diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index 65e7749..bc0d4d3 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -3,6 +3,7 @@ use crate::db::main::MainPool; use crate::db::{self, main::user::schema::Role}; use crate::rest::auth::Auth; use crate::rest::error::RestApiError; +use actix_web::delete; use actix_web::get; use actix_web::http::header; use actix_web::post; @@ -190,6 +191,44 @@ pub async fn update_username( Ok(Json(MeResponse::from(&updated_user))) } +#[derive(Serialize, Deserialize)] +pub struct NostrIdentityResponse { + /// Bech32 npub (`npub1...`) currently linked to the account, or `null`. + pub npub: Option, +} + +/// `GET /v4/users/me/nostr` +/// +/// Returns the Nostr pubkey currently linked to the authenticated account +/// (or `null`). A thin read of the same `npub` exposed on `GET /me`, kept +/// as a dedicated sub-resource so a client can poll just the link state. +#[get("/me/nostr")] +pub async fn get_nostr( + auth: Auth, + _pool: Data, +) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + Ok(Json(NostrIdentityResponse { npub: user.npub })) +} + +/// `DELETE /v4/users/me/nostr` +/// +/// Clears the Nostr pubkey linked to the authenticated account. Requires +/// only account auth (Bearer) — removing your own link needs no NIP-98 +/// proof. Idempotent: succeeds with `npub: null` even if nothing was +/// linked. +#[delete("/me/nostr")] +pub async fn delete_nostr( + auth: Auth, + pool: Data, +) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + db::main::user::queries::set_npub(user.id, None, &pool) + .await + .map_err(|_| RestApiError::database())?; + Ok(Json(NostrIdentityResponse { npub: None })) +} + #[post("/{username}/tokens")] pub async fn create_token( req: actix_web::HttpRequest, @@ -363,6 +402,150 @@ mod test { Ok(()) } + // Inserts a user (optionally with an npub) plus an access token "secret" + // bound to it, returning the user. Mirrors the inline setup used by the + // `me` tests above. + async fn user_with_token( + name: &str, + npub: Option<&str>, + pool: &crate::db::main::MainPool, + ) -> Result { + let user = match npub { + Some(npub) => { + db::main::user::queries::insert_with_npub(name, "", npub, &[Role::User], pool) + .await? + } + None => db::main::user::queries::insert(name, "", pool).await?, + }; + db::main::access_token::queries::insert( + user.id, + "".into(), + "secret".into(), + vec![Role::User], + pool, + ) + .await?; + Ok(user) + } + + #[test] + async fn get_nostr_unauthenticated_returns_401() -> Result<()> { + let app = test::init_service( + App::new() + .app_data(Data::new(pool())) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get().uri("/users/me/nostr").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn get_nostr_returns_null_when_unlinked() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, None); + Ok(()) + } + + #[test] + async fn get_nostr_returns_linked_npub() -> Result<()> { + let pool = pool(); + user_with_token("nostr_user", Some("npub1example"), &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some("npub1example".to_string())); + Ok(()) + } + + #[test] + async fn delete_nostr_unauthenticated_returns_401() -> Result<()> { + let app = test::init_service( + App::new() + .app_data(Data::new(pool())) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete().uri("/users/me/nostr").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn delete_nostr_clears_link() -> Result<()> { + let pool = pool(); + let user = user_with_token("nostr_user", Some("npub1example"), &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool.clone())) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, None); + + // The link is actually gone from the database. + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, None); + Ok(()) + } + + #[test] + async fn delete_nostr_idempotent_when_unlinked() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + Ok(()) + } + fn make_password_hash(password: &str) -> String { let salt = SaltString::generate(&mut OsRng); Argon2::default() From 6c137a34d4882666a4d642f66285bbc3264b680a Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:47:16 +0200 Subject: [PATCH 3/6] refactor(users): drop unused pool param from get_nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #95 flagged it: get_nostr never touches the pool — the npub is already on auth.user, and the Auth extractor reads the pool from app_data itself, so the Data handler param was dead weight. Removing it makes the signature honest. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/rest/v4/users.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index bc0d4d3..92d7ab0 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -203,10 +203,7 @@ pub struct NostrIdentityResponse { /// (or `null`). A thin read of the same `npub` exposed on `GET /me`, kept /// as a dedicated sub-resource so a client can poll just the link state. #[get("/me/nostr")] -pub async fn get_nostr( - auth: Auth, - _pool: Data, -) -> Result, RestApiError> { +pub async fn get_nostr(auth: Auth) -> Result, RestApiError> { let user = auth.user.ok_or_else(RestApiError::unauthorized)?; Ok(Json(NostrIdentityResponse { npub: user.npub })) } From ec9e6a8339d8b8067547e1b2558354a9d0fe284f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:47:55 +0200 Subject: [PATCH 4/6] docs(users): document GET and DELETE /v4/users/me/nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #95 flagged the public REST docs as incomplete — the new identity sub-resource endpoints weren't listed. Add the Available Endpoints entries plus request/response sections for reading and clearing the linked npub. (PUT link/replace will be documented when that endpoint lands.) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/rest/v4/users.md | 65 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/rest/v4/users.md b/docs/rest/v4/users.md index 20a794b..492caa3 100644 --- a/docs/rest/v4/users.md +++ b/docs/rest/v4/users.md @@ -9,6 +9,8 @@ This document describes the endpoints for interacting with users in REST API v4. - [Create Token](#create-token) - [Change Password](#change-password) - [Update Username](#update-username) +- [Get Linked Nostr Identity](#get-linked-nostr-identity) +- [Unlink Nostr Identity](#unlink-nostr-identity) ### Get Authenticated User @@ -214,4 +216,65 @@ curl -X PUT https://api.btcmap.org/v4/users/me/username \ |-------|------|-------------| | id | Number | User ID | | name | String | Updated username | -| roles | Array | List of user roles | \ No newline at end of file +| roles | Array | List of user roles | + +### Get Linked Nostr Identity + +Returns the Nostr pubkey currently linked to the authenticated account, or `null` if none is linked. Requires a valid Bearer token. This is the same `npub` exposed on [Get Authenticated User](#get-authenticated-user), offered as a dedicated sub-resource so a client can poll just the link state. + +#### Example Request + +```bash +curl https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Returns the linked npub (or null) | +| 401 | Unauthorized - Missing or invalid token | + +##### Example Response (200 OK) + +```json +{ + "npub": "npub1..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | String \| null | Bech32 npub of the linked Nostr identity, or `null` if none is linked | + +### Unlink Nostr Identity + +Clears the Nostr pubkey linked to the authenticated account. Requires a valid Bearer token only — removing your own link needs no NIP-98 proof. Idempotent: succeeds with `npub: null` even if nothing was linked. + +#### Example Request + +```bash +curl -X DELETE https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Link cleared (or already absent) | +| 401 | Unauthorized - Missing or invalid token | +| 500 | Internal Server Error - Database error | + +##### Example Response (200 OK) + +```json +{ + "npub": null +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | null | Always `null` after unlinking | \ No newline at end of file From c6b890ca8334f832dffc1c6afa77f0b348e7fcb1 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:54:49 +0200 Subject: [PATCH 5/6] feat(users): add PUT /v4/users/me/nostr to link a Nostr pubkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links (or replaces) the Nostr pubkey on an already-authenticated account. This needs TWO credentials at once — a Bearer token to say which account, and a NIP-98 signature to prove control of the pubkey being linked — but Auth and NostrAuth both read the Authorization header. Resolve it by carrying the proof on a dedicated header: - Factor the NIP-98 verification in nostr_auth.rs into a shared `verified_npub(req, header)` helper. - Add a `NostrProof` extractor that reads `X-Nostr-Authorization` (new const `X_NOSTR_AUTHORIZATION`), reusing the same ApiBaseUrl-pinned verification as NostrAuth. NostrAuth keeps reading `Authorization`. Handler: Auth identifies the account, NostrProof proves the pubkey. Conflict is checked at the application level — if the proven npub is already linked to a different account, return 400; re-linking your own npub is an idempotent 200. The empty body keeps the NIP-98 binding to just `u`+`method` (the nip98 module does no body-hash binding). There is deliberately NO DB migration: with no UNIQUE index on user.npub yet, the select-then-set has a documented TOCTOU window that the maintainer-owned partial unique index is meant to close. Flagged in code and reserved for that follow-up rather than pre-empted here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main.rs | 1 + src/rest/nostr_auth.rs | 124 +++++++++++++--------- src/rest/v4/users.rs | 232 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 50 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7d7e707..bf7c242 100644 --- a/src/main.rs +++ b/src/main.rs @@ -212,6 +212,7 @@ async fn main() -> Result<()> { .service(rest::v4::users::change_password) .service(rest::v4::users::update_username) .service(rest::v4::users::get_nostr) + .service(rest::v4::users::put_nostr) .service(rest::v4::users::delete_nostr) .service(rest::v4::users::create_token), ), diff --git a/src/rest/nostr_auth.rs b/src/rest/nostr_auth.rs index 7bae45f..0b93a6b 100644 --- a/src/rest/nostr_auth.rs +++ b/src/rest/nostr_auth.rs @@ -15,6 +15,57 @@ use crate::service::nip98; #[derive(Clone)] pub struct ApiBaseUrl(pub String); +/// Header carrying the NIP-98 proof on endpoints that ALSO need Bearer +/// auth (e.g. `PUT /v4/users/me/nostr`, which links a pubkey to an +/// already-authenticated account). The standard `Authorization` header is +/// taken by the Bearer token there, so the signed Nostr event rides this +/// one instead. Endpoints authenticated by Nostr alone (the sign-in +/// endpoint) keep using `Authorization: Nostr ...` via [`NostrAuth`]. +pub const X_NOSTR_AUTHORIZATION: &str = "x-nostr-authorization"; + +/// Verify a NIP-98 event carried in `header_name` against the trusted +/// `ApiBaseUrl` + this request's path/method, returning the bech32 npub on +/// success. Returns `None` when state is missing, the header is absent or +/// not a `Nostr ...` value, or verification fails. Shared by [`NostrAuth`] +/// (reads `Authorization`) and [`NostrProof`] (reads `X-Nostr-Authorization`). +fn verified_npub(req: &HttpRequest, header_name: impl header::AsHeaderName) -> Option { + // Without a trusted base URL we can't safely verify the `u` tag, so + // refuse to attempt verification (matches the `Auth` extractor's + // fail-closed pattern when state is missing). + let base_url = req.app_data::>()?; + + let payload = req + .headers() + .get(header_name) + .and_then(|h| h.to_str().ok()) + .and_then(nip98::extract_nostr_auth)?; + + // 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) => Some(event.npub), + Err(e) => { + tracing::debug!( + error = %e, + url = %full_url, + method = %method, + "NIP-98 verification failed" + ); + None + } + } +} + /// 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. @@ -27,7 +78,8 @@ pub struct ApiBaseUrl(pub String); /// - verified under a valid Schnorr signature /// /// The `npub` is bech32-encoded (`npub1...`), matching the encoding used by -/// the `user.npub` DB column. +/// the `user.npub` DB column. The signed event is read from the standard +/// `Authorization: Nostr ...` header. pub struct NostrAuth { pub npub: Option, } @@ -37,55 +89,27 @@ impl FromRequest for NostrAuth { 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 }) - } - } - }) + let npub = verified_npub(req, header::AUTHORIZATION); + Box::pin(async move { Ok(NostrAuth { npub }) }) + } +} + +/// Same NIP-98 guarantees as [`NostrAuth`], but reads the signed event from +/// the [`X_NOSTR_AUTHORIZATION`] header. Use this on endpoints that need +/// BOTH a Bearer token (to identify the account, via `Auth`) AND a Nostr +/// signature (to prove control of the pubkey) — the two can't share the +/// `Authorization` header. +pub struct NostrProof { + pub npub: Option, +} + +impl FromRequest for NostrProof { + type Error = actix_web::Error; + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + let npub = verified_npub(req, X_NOSTR_AUTHORIZATION); + Box::pin(async move { Ok(NostrProof { npub }) }) } } diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index 92d7ab0..27fc0e2 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -3,6 +3,7 @@ use crate::db::main::MainPool; use crate::db::{self, main::user::schema::Role}; use crate::rest::auth::Auth; use crate::rest::error::RestApiError; +use crate::rest::nostr_auth::NostrProof; use actix_web::delete; use actix_web::get; use actix_web::http::header; @@ -208,6 +209,55 @@ pub async fn get_nostr(auth: Auth) -> Result, RestAp Ok(Json(NostrIdentityResponse { npub: user.npub })) } +/// `PUT /v4/users/me/nostr` +/// +/// Links (or replaces) the Nostr pubkey on the authenticated account. +/// Requires TWO credentials: a Bearer token (`Authorization`, via [`Auth`]) +/// to say *which account*, and a NIP-98 proof (`X-Nostr-Authorization`, via +/// [`NostrProof`]) to prove control of the pubkey being linked. The request +/// body is empty; the proof event must sign `u = /v4/users/me/nostr` +/// with method `PUT`. +/// +/// Conflict handling is application-level: if the proven npub is already +/// linked to a *different* account, returns 400. Idempotent: re-linking the +/// npub already on this account returns 200. +/// +/// NOTE (concurrency): there is no UNIQUE index on `user.npub` yet, so the +/// `select_by_npub` check and the `set_npub` write are not atomic — two +/// concurrent PUTs linking the same npub to two different accounts could +/// both pass the check and both succeed (TOCTOU). This is accepted for now; +/// the maintainer-owned partial unique index on `user.npub` is what closes +/// the window (after which a UNIQUE violation on write could be mapped to +/// 400). Do not add that index here. +#[put("/me/nostr")] +pub async fn put_nostr( + auth: Auth, + proof: NostrProof, + pool: Data, +) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + let npub = proof.npub.ok_or_else(RestApiError::unauthorized)?; + + // Refuse to steal a pubkey already linked to someone else. Linking the + // npub this account already has is a no-op that still returns 200. + if let Some(existing) = db::main::user::queries::select_by_npub(npub.clone(), &pool) + .await + .map_err(|_| RestApiError::database())? + { + if existing.id != user.id { + return Err(RestApiError::invalid_input( + "npub already linked to another account", + )); + } + } + + db::main::user::queries::set_npub(user.id, Some(npub.clone()), &pool) + .await + .map_err(|_| RestApiError::database())?; + + Ok(Json(NostrIdentityResponse { npub: Some(npub) })) +} + /// `DELETE /v4/users/me/nostr` /// /// Clears the Nostr pubkey linked to the authenticated account. Requires @@ -308,12 +358,36 @@ mod test { use super::*; use crate::db::main::test::pool; use crate::db::main::user::schema::Role; + use crate::rest::nostr_auth::{ApiBaseUrl, X_NOSTR_AUTHORIZATION}; use crate::{db, Result}; use actix_web::http::header; use actix_web::http::StatusCode; use actix_web::test::TestRequest; use actix_web::web::{scope, Data}; use actix_web::{test, App}; + 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}; + + // Trusted base URL the NIP-98 `u` tag must bind to in PUT /me/nostr tests. + const BASE: &str = "https://api.example.test"; + + // Base64-encoded NIP-98 event signing `url` with `method`, for the + // `X-Nostr-Authorization` header. Mirrors the helper in nostr.rs/nostr_auth.rs. + 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()) + } #[test] async fn me_unauthenticated_returns_401() -> Result<()> { @@ -543,6 +617,164 @@ mod test { Ok(()) } + // Builds the PUT /me/nostr test app: pool + the ApiBaseUrl the NIP-98 + // proof binds to, mounted at /users so the signed `u` is BASE/users/me/nostr. + fn put_app( + pool: crate::db::main::MainPool, + ) -> 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(pool)) + .app_data(Data::new(ApiBaseUrl(BASE.to_string()))) + .service(scope("/users").service(put_nostr)) + } + + const PUT_URL: &str = "/users/me/nostr"; + + #[test] + async fn put_nostr_links_pubkey() -> Result<()> { + let pool = pool(); + let user = user_with_token("plain_user", None, &pool).await?; + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool.clone())).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub.clone())); + + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, Some(npub)); + Ok(()) + } + + #[test] + async fn put_nostr_replaces_existing_link() -> Result<()> { + let pool = pool(); + let user = user_with_token("nostr_user", Some("npub1old"), &pool).await?; + let keys = Keys::generate(); + let new_npub = keys.public_key().to_bech32().unwrap(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool.clone())).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(new_npub.clone())); + + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, Some(new_npub)); + Ok(()) + } + + #[test] + async fn put_nostr_idempotent_when_same_user() -> Result<()> { + // The account already owns this npub; re-linking it returns 200. + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + user_with_token("nostr_user", Some(&npub), &pool).await?; + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub)); + Ok(()) + } + + #[test] + async fn put_nostr_conflict_returns_400() -> Result<()> { + // npub is already linked to a different account. + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + // Account A owns the pubkey. + db::main::user::queries::insert_with_npub("owner", "", &npub, &[Role::User], &pool).await?; + // Account B (the caller) tries to claim it. + user_with_token("claimer", None, &pool).await?; + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + Ok(()) + } + + #[test] + async fn put_nostr_missing_bearer_returns_401() -> Result<()> { + let pool = pool(); + let keys = Keys::generate(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn put_nostr_missing_proof_returns_401() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn put_nostr_proof_for_wrong_url_returns_401() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + let keys = Keys::generate(); + // Proof signs a different path than the request targets. + let proof = signed_nip98(&keys, &format!("{BASE}/users/me/different"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + fn make_password_hash(password: &str) -> String { let salt = SaltString::generate(&mut OsRng); Argon2::default() From f9f4ddad59e53e50a145139207fc99e685105272 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Wed, 3 Jun 2026 17:55:16 +0200 Subject: [PATCH 6/6] docs(users): document PUT /v4/users/me/nostr Document the link/replace endpoint: the dual-credential requirement (Bearer + the NIP-98 proof on the X-Nostr-Authorization header), the exact u/method the proof must sign, and the 200/400/401 responses. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/rest/v4/users.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/rest/v4/users.md b/docs/rest/v4/users.md index 492caa3..4304379 100644 --- a/docs/rest/v4/users.md +++ b/docs/rest/v4/users.md @@ -10,6 +10,7 @@ This document describes the endpoints for interacting with users in REST API v4. - [Change Password](#change-password) - [Update Username](#update-username) - [Get Linked Nostr Identity](#get-linked-nostr-identity) +- [Link Nostr Identity](#link-nostr-identity) - [Unlink Nostr Identity](#unlink-nostr-identity) ### Get Authenticated User @@ -248,6 +249,44 @@ curl https://api.btcmap.org/v4/users/me/nostr \ |-------|------|-------------| | npub | String \| null | Bech32 npub of the linked Nostr identity, or `null` if none is linked | +### Link Nostr Identity + +Links (or replaces) the Nostr pubkey on the authenticated account. This requires **two** credentials at once: + +- `Authorization: Bearer ` — identifies the account being modified. +- `X-Nostr-Authorization: Nostr ` — a NIP-98 event proving control of the pubkey being linked. + +The two cannot share the `Authorization` header, so the NIP-98 proof is carried on the dedicated `X-Nostr-Authorization` header. The request body is empty. The proof event must sign `u = /v4/users/me/nostr` with method `PUT` (the `u`/`method` are matched against the server's configured base URL and the actual request method — both are case-sensitive). + +#### Example Request + +```bash +curl -X PUT https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " \ + -H "X-Nostr-Authorization: Nostr " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Pubkey linked (or already linked to this account) | +| 400 | Bad Request - The npub is already linked to a different account | +| 401 | Unauthorized - Missing/invalid Bearer token, or missing/invalid NIP-98 proof | +| 500 | Internal Server Error - Database error | + +##### Example Response (200 OK) + +```json +{ + "npub": "npub1..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | String | Bech32 npub now linked to the account | + ### Unlink Nostr Identity Clears the Nostr pubkey linked to the authenticated account. Requires a valid Bearer token only — removing your own link needs no NIP-98 proof. Idempotent: succeeds with `npub: null` even if nothing was linked.