Skip to content
Merged
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
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Nostr Configuration
NOSTR_RELAYS=wss://relay.mostro.network

# Mostro daemon public key (hex format, 64 chars)
# This is the pubkey that signs kind 1059 events.
MOSTRO_PUBKEY=82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390
# The list of trusted Mostro instance pubkeys is compiled into the binary
# from config/trusted_mostro_pubkeys.json. To add or remove instances, edit
# that file and rebuild. There is no MOSTRO_PUBKEY environment variable
# anymore.
#
# Runtime feature flag for the trusted-Mostro-instance whitelist on
# /api/register. When false (the default), the embedded list is ignored and
# the `mostro_pubkey` field on registration is permissive. Set to true ONLY
# after the mobile client is rolled out with support for sending the field;
# otherwise older clients that don't send it will be rejected with 403.
TRUSTED_WHITELIST_ENABLED=false

# Server Keypair (REQUIRED)
# Generate with: openssl rand -hex 32
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ target
firebase-service-account.json
*.json
!.planning/**/*.json
!config/trusted_mostro_pubkeys.json

# Logs
*.log
Expand Down
30 changes: 29 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ These are the privacy and compatibility invariants of the project. Reintroducing
- Inbound `X-Request-Id` is stripped; server generates UUIDv4 per request.
- Dispatch happens in a `tokio::spawn` task detached from the response, bounded by `Arc<Semaphore>(50)`.

3. **Backwards compatibility of the existing endpoints.** `/api/health`, `/api/info`, `/api/status`, `/api/register`, `/api/unregister` response bodies are byte-identical to fixtures captured before v1.1. Field order on `RegisterResponse` is `success, message, platform`.
3. **Backwards compatibility of the existing endpoints.** `/api/health`, `/api/info`, `/api/status`, `/api/register`, `/api/unregister` response bodies are byte-identical to fixtures captured before v1.1. Field order on `RegisterResponse` is `success, message, platform`. The `mostro_pubkey` field added to `RegisterTokenRequest` is request-only and does not change response shapes.

**Exception (off by default):** when `TRUSTED_WHITELIST_ENABLED=true` AND the embedded whitelist is non-empty, `/api/register` MAY return a new `403 Forbidden` with one of two distinct bodies — `{"success":false,"message":"Mostro instance pubkey required"}` (missing field) or `{"success":false,"message":"Mostro instance not trusted"}` (untrusted value). The flag defaults to `false` precisely so the byte-identical fixture set continues to hold for clients that pre-date the feature; only flip it after the mobile rollout.

4. **Token store is in-memory only.** No persistence to disk for `trade_pubkey -> device_token`. UnifiedPush endpoints are the only on-disk state (atomic JSON write to `data/unifiedpush_endpoints.json`).

Expand Down Expand Up @@ -60,6 +62,7 @@ These are the privacy and compatibility invariants of the project. Reintroducing
src/
├── main.rs # Boot + wiring
├── config.rs # Config::from_env (typed env-var loader)
├── trusted_pubkeys.rs # Compile-time whitelist (include_str! the JSON below)
├── api/
│ ├── routes.rs # /health, /info, /status, /register, /unregister + AppState
│ ├── notify.rs # /api/notify handler + request_id_mw
Expand All @@ -76,8 +79,33 @@ src/
└── utils/
├── log_pubkey.rs # Salted BLAKE3 keyed hash
└── batching.rs # Reserved (unused at runtime)

config/
└── trusted_mostro_pubkeys.json # JSON array of 64-hex pubkeys; mirrors mobile/lib/core/config/communities.dart
```

## Trusted Mostro instance whitelist

`/api/register` filters registrations against a compile-time whitelist of
trusted Mostro instance pubkeys, embedded into the binary via
`include_str!("../config/trusted_mostro_pubkeys.json")`. The mobile client is
expected to send the pubkey of the selected Mostro instance in the
`mostro_pubkey` field of the registration body.

- An empty JSON array disables the whitelist (permissive mode); the field
is then ignored.
- A non-empty array activates the filter; missing or unknown
`mostro_pubkey` values are rejected with `403 Forbidden`. Malformed
values (length or hex) return `400 Bad Request`.
- This filter is honour-system only — the device cryptographically proves
nothing about which Mostro instance it actually uses. It will be hardened
in a future phase. Do NOT remove the whitelist code on the basis that it
"isn't really enforcing anything"; it deliberately blocks well-behaved
clients from arbitrary instances and the harder protocol depends on this
field staying in the request shape.
- The previous `MOSTRO_PUBKEY` environment variable has been removed; it
was only used as log context and was never an authors filter.

## Common commands

```bash
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ FROM rust:1.83 as builder
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY config ./config

RUN cargo build --release

Expand Down
7 changes: 7 additions & 0 deletions config/trusted_mostro_pubkeys.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
"00000235a3e904cfe1213a8a54d6f1ec1bef7cc6bfaabd6193e82931ccf1366a",
"0000cc02101ec29eea9ce623258752b9d7da66c27845ed26846dd0b0fc736b40",
"00000978acc594c506976c655b6decbf2d4af25ffdaa6680f2a9568b0a88441b",
"00007cb3305fb972f5cc83f83a8fbca1e64e93c9d1369880a9fd62ef95d23f91",
"82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"
]
1 change: 0 additions & 1 deletion deploy-fly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ echo "📝 Configurando secrets..."
# Configurar todos los secrets
flyctl secrets set \
NOSTR_RELAYS="wss://relay.mostro.network" \
MOSTRO_PUBKEY="82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390" \
SERVER_PRIVATE_KEY="2dfb72f7e130b4c6f971c5bac364b9f854f2409de51fb53d4dbd3e17bd69b98e" \
FIREBASE_PROJECT_ID="mostro-mobile" \
FIREBASE_SERVICE_ACCOUNT_PATH="/secrets/mostro-mobile-firebase-adminsdk-fbsvc-1ff8f6232c.json" \
Expand Down
55 changes: 49 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,17 @@ Request:
{
"trade_pubkey": "<64-char hex>",
"token": "<fcm-or-unifiedpush-token>",
"platform": "android"
"platform": "android",
"mostro_pubkey": "<64-char hex of the Mostro instance>"
}
```

| Field | Type | Description |
|----------------|--------|----------------------------------------------------------|
| `trade_pubkey` | string | 64 hex characters |
| `token` | string | FCM device token, or UnifiedPush endpoint URL |
| `platform` | string | `"android"` or `"ios"` |
| Field | Type | Description |
|-----------------|--------|------------------------------------------------------------------------------------------------------------------------|
| `trade_pubkey` | string | 64 hex characters |
| `token` | string | FCM device token, or UnifiedPush endpoint URL |
| `platform` | string | `"android"` or `"ios"` |
| `mostro_pubkey` | string | 64 hex characters. Optional on the wire; required when the trusted-instance whitelist is non-empty (see below). |

Success — `200 OK`:

Expand All @@ -103,6 +105,47 @@ Possible validation errors:
- `trade_pubkey` not 64 hex characters
- `token` empty
- `platform` not `"android"` or `"ios"`
- `mostro_pubkey` present but not 64 hex characters

Trusted-instance filter — `403 Forbidden`:

The filter is gated by `TRUSTED_WHITELIST_ENABLED` (default `false`) and
only fires when the runtime flag is `true` AND the embedded whitelist is
non-empty (see [configuration.md](./configuration.md)). When it does
fire, the response body distinguishes two cases so clients can react
without parsing logs:

```json
{
"success": false,
"message": "Mostro instance pubkey required"
}
```

Returned when the `mostro_pubkey` field is absent. Typical for clients
that pre-date the feature.

```json
{
"success": false,
"message": "Mostro instance not trusted"
}
```

Returned when the field is present, hex-valid, but the value is not on
the whitelist.

The whitelist is compiled into the binary from
`config/trusted_mostro_pubkeys.json`. The filter is honour-system: there
is no cryptographic proof binding the device to the declared instance.

**Mobile client compatibility.** The `mostro_pubkey` field is supported
by mobile client `vX.Y.Z` and later (TODO: pin the released version once
the mobile-side change merges). Clients older than that release will
receive `403 "Mostro instance pubkey required"` whenever
`TRUSTED_WHITELIST_ENABLED=true`. Operators should keep the flag at
`false` during the rollout window and flip it on after the mobile
release is in users' hands.

## POST /api/unregister

Expand Down
45 changes: 41 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,47 @@ cp .env.example .env

## Nostr listener

| Variable | Default | Description |
|-----------------|----------------------------------------------------------------------|----------------------------------------------------------|
| `MOSTRO_PUBKEY` | `82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390` | Hex pubkey of the Mostro daemon. Used for log context only — it is NOT applied as an `authors` filter on the listener (privacy invariant; see [architecture.md](./architecture.md)). |
The listener has no instance-specific configuration. It does NOT filter
events by `authors` (privacy invariant; see [architecture.md](./architecture.md)).

## Trusted Mostro instance whitelist

The set of Mostro instance pubkeys allowed to register devices is compiled
into the binary from `config/trusted_mostro_pubkeys.json` at build time.
Activation is gated by a runtime feature flag, so the JSON can ship
populated while the filter stays inert until the mobile rollout is ready.

| Variable | Default | Description |
|------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------|
| `TRUSTED_WHITELIST_ENABLED` | `false` | When `true`, `/api/register` rejects requests whose declared `mostro_pubkey` is missing or not on the embedded whitelist. |

Activation rule: the filter on `/api/register` only fires when **both**
`TRUSTED_WHITELIST_ENABLED=true` **and** the embedded whitelist is
non-empty. Either side off => permissive mode and the `mostro_pubkey`
field is ignored.

About the embedded JSON:

- The file must contain a JSON array of 64-character hex pubkeys
(lowercase preferred; `load()` canonicalizes to lowercase regardless).
- An empty array keeps the filter permissive even when the flag is on.
- The file is parsed at startup; malformed JSON or any entry that is not
64 hex characters causes the process to panic immediately (fail-fast).
- Editing the list requires a rebuild because the JSON is embedded at
compile time via `include_str!`. Toggling the flag does not.

When the filter rejects, the response is `403 Forbidden` with one of two
distinct bodies (see [api.md](./api.md) for the wire details):

- `{"success":false,"message":"Mostro instance pubkey required"}` when the
field is absent — typical for an old mobile client that pre-dates the
feature.
- `{"success":false,"message":"Mostro instance not trusted"}` when the
field is present but its value is not on the whitelist.

To change the list, edit `config/trusted_mostro_pubkeys.json` and rebuild.
To turn the filter on/off without rebuilding, flip
`TRUSTED_WHITELIST_ENABLED`.

## HTTP server

Expand Down Expand Up @@ -87,7 +125,6 @@ RUST_LOG=mostro_push_backend=debug,actix_web=info
```bash
# Nostr
NOSTR_RELAYS=wss://relay.mostro.network
MOSTRO_PUBKEY=82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390

# Server
SERVER_HOST=0.0.0.0
Expand Down
1 change: 0 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ Or do it by hand:
```bash
flyctl secrets set \
NOSTR_RELAYS="wss://relay.mostro.network" \
MOSTRO_PUBKEY="82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390" \
FIREBASE_PROJECT_ID="your-project-id" \
FIREBASE_SERVICE_ACCOUNT_PATH="/secrets/firebase-service-account.json" \
FCM_ENABLED="true" \
Expand Down
2 changes: 1 addition & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
pub mod routes;
pub mod notify;
pub mod rate_limit;
pub mod routes;
#[cfg(test)]
pub mod test_support;
42 changes: 22 additions & 20 deletions src/api/notify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;

use governor::clock::Clock;
use crate::api::rate_limit::rate_limited_response;
use crate::api::routes::AppState;
use crate::utils::log_pubkey::log_pubkey;
use governor::clock::Clock;

/// Request body for POST /api/notify.
///
Expand Down Expand Up @@ -57,8 +57,7 @@ pub async fn notify_token(
warn!("notify: invalid trade_pubkey format");
return HttpResponse::BadRequest().json(NotifyError {
success: false,
message: "Invalid trade_pubkey format (expected 64 hex characters)"
.to_string(),
message: "Invalid trade_pubkey format (expected 64 hex characters)".to_string(),
});
}

Expand Down Expand Up @@ -96,14 +95,8 @@ pub async fn notify_token(
// CONC-2-safe: get() drops the RwLock before returning.
if let Some(token) = token_store.get(&pubkey).await {
match dispatcher.dispatch_silent(&token).await {
Ok(_outcome) => info!(
"notify: dispatched pk={}",
task_log_pk
),
Err(e) => warn!(
"notify: dispatch failed pk={} err={}",
task_log_pk, e
),
Ok(_outcome) => info!("notify: dispatched pk={}", task_log_pk),
Err(e) => warn!("notify: dispatch failed pk={} err={}", task_log_pk, e),
}
}
// None case (pubkey not registered): silently no-op.
Expand Down Expand Up @@ -138,23 +131,22 @@ pub async fn request_id_mw(

res.headers_mut().insert(
HeaderName::from_static("x-request-id"),
HeaderValue::from_str(&id)
.expect("uuid string is always valid header value"),
HeaderValue::from_str(&id).expect("uuid string is always valid header value"),
);
Ok(res)
}

#[cfg(test)]
mod tests {
use super::*;
use actix_web::{http::StatusCode, test, web, App};
use crate::api::routes::configure;
use crate::api::rate_limit::TrustProxyHeaders;
use crate::api::routes::configure;
use crate::api::test_support::{
make_app_state, make_test_components, build_test_actix_app,
register_test_pubkey, StubPushService, TEST_PUBKEY, TEST_PUBKEY_2,
build_test_actix_app, make_app_state, make_test_components, register_test_pubkey,
StubPushService, TEST_PUBKEY, TEST_PUBKEY_2,
};
use crate::store::Platform;
use actix_web::{http::StatusCode, test, web, App};
use std::sync::Arc;
use uuid::Uuid;

Expand Down Expand Up @@ -215,7 +207,11 @@ mod tests {
.to_request();

let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::ACCEPTED, "anti-CRIT-2 always-202");
assert_eq!(
resp.status(),
StatusCode::ACCEPTED,
"anti-CRIT-2 always-202"
);

for _ in 0..20 {
tokio::task::yield_now().await;
Expand Down Expand Up @@ -283,8 +279,14 @@ mod tests {
.expect("x-request-id header MUST be present on every /notify response")
.to_str()
.unwrap();
assert_ne!(id_value, "spoofed-by-client-12345", "client value must be overwritten");
assert!(Uuid::parse_str(id_value).is_ok(), "x-request-id must be UUIDv4 parseable");
assert_ne!(
id_value, "spoofed-by-client-12345",
"client value must be overwritten"
);
assert!(
Uuid::parse_str(id_value).is_ok(),
"x-request-id must be UUIDv4 parseable"
);

// 400 path — header MUST also be present.
let req = test::TestRequest::post()
Expand Down
Loading
Loading