Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions docs/rest/v4/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ 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)
- [Link Nostr Identity](#link-nostr-identity)
- [Unlink Nostr Identity](#unlink-nostr-identity)

### Get Authenticated User

Expand Down Expand Up @@ -36,7 +39,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..."
}
```

Expand All @@ -47,6 +51,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

Expand Down Expand Up @@ -212,4 +217,103 @@ 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 |
| 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 <your-token>"
```

#### 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 |

### Link Nostr Identity

Links (or replaces) the Nostr pubkey on the authenticated account. This requires **two** credentials at once:

- `Authorization: Bearer <token>` — identifies the account being modified.
- `X-Nostr-Authorization: Nostr <base64-event>` — 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 = <api-base-url>/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 <your-token>" \
-H "X-Nostr-Authorization: Nostr <base64-encoded-nip98-event>"
```

#### 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.

#### Example Request

```bash
curl -X DELETE https://api.btcmap.org/v4/users/me/nostr \
-H "Authorization: Bearer <your-token>"
```

#### 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 |
1 change: 0 additions & 1 deletion src/db/main/user/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, pool: &Pool) -> Result<User> {
pool.get()
.await?
Expand Down
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ 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::put_nostr)
.service(rest::v4::users::delete_nostr)
.service(rest::v4::users::create_token),
),
)
Expand Down
124 changes: 74 additions & 50 deletions src/rest/nostr_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// 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::<Data<ApiBaseUrl>>()?;

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.
Expand All @@ -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<String>,
}
Expand All @@ -37,55 +89,27 @@ impl FromRequest for NostrAuth {
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;

fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let req = req.clone();
let base_url = req.app_data::<Data<ApiBaseUrl>>().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<String>,
}

impl FromRequest for NostrProof {
type Error = actix_web::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;

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 }) })
}
}

Expand Down
Loading
Loading