Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 931dd9eec9 | |||
| 4367644a61 |
@@ -7,17 +7,20 @@ Nostr network, forked from
|
||||
Floonet is a network of Nostr relays for the Grin community: anyone can
|
||||
run one, and anyone can run a name authority on it so people can claim
|
||||
(and optionally pay for) a `name@domain` identity. floonet-rs keeps the
|
||||
upstream relay core intact and adds three configurable, modular features:
|
||||
upstream relay core intact and adds four configurable, modular features:
|
||||
|
||||
* An **event kind whitelist** (the keystone): default-deny admission.
|
||||
The relay accepts ONLY the kinds it is configured to allow and rejects
|
||||
everything else. The shipped set is
|
||||
`0, 3, 5, 13, 1059, 10002, 10050, 27235`.
|
||||
everything else. The shipped default set covers the Goblin wallet and
|
||||
the Magick Market marketplace (see the whitelist section).
|
||||
* A **public-note lockdown**: kinds `1` (text notes) and `30023`
|
||||
(long-form articles) are accepted only from an operator-chosen author
|
||||
list, closed by default so no one can spam public notes to your relay.
|
||||
* **Authentication**: NIP-42, with optional require-auth-to-write and an
|
||||
author whitelist.
|
||||
* A **built-in name authority**: `name@domain` NIP-05 identities with
|
||||
NIP-98 authenticated self-service registration, served in-process on
|
||||
the relay's own subdomain — no separate hostname to run. Optionally
|
||||
the relay's own subdomain - no separate hostname to run. Optionally
|
||||
paid in GRIN through GoblinPay.
|
||||
|
||||
The public relay metadata stays neutral on purpose: the NIP-11 document
|
||||
@@ -73,19 +76,50 @@ Requires a protobuf compiler (`protoc`) for the gRPC extension point.
|
||||
|
||||
```toml
|
||||
[limits]
|
||||
event_kind_allowlist = [0, 3, 5, 13, 1059, 10002, 10050, 27235]
|
||||
# Leaving this unset keeps the built-in Floonet default set below.
|
||||
event_kind_allowlist = [
|
||||
0, 1, 3, 5, 7, 13, 14, 16, 17, 1059, 1111, 10000, 10002, 10050, 24133,
|
||||
27235, 30000, 30003, 30023, 30078, 30402, 30405, 30406, 31990,
|
||||
]
|
||||
```
|
||||
|
||||
The default set is the union of what the Goblin wallet and the Magick
|
||||
Market marketplace use: profiles/contacts/deletes, gift wraps (1059), relay
|
||||
and DM-relay lists, NIP-98 auth, marketplace listings/collections/shipping
|
||||
(30402/30405/30406) and order events, plus text notes (1) and long-form
|
||||
articles (30023). The two public-note kinds are additionally author-locked
|
||||
(next section).
|
||||
|
||||
Fail-closed semantics, enforced in the write path before anything is
|
||||
queued for persistence:
|
||||
|
||||
* The listed kinds are accepted; **everything else is rejected** with an
|
||||
`OK false` / `blocked:` message.
|
||||
* Removing the line keeps the built-in Floonet set. There is no
|
||||
* Removing the line keeps the built-in Floonet default set. There is no
|
||||
allow-all: an empty list denies everything.
|
||||
* To add a kind, add it to the list and restart. Never narrow the list
|
||||
below what your users' wallets already depend on.
|
||||
|
||||
## Public-note lockdown
|
||||
|
||||
Kinds `1` (text notes) and `30023` (long-form articles) are accepted
|
||||
**only** from an operator-chosen list of authors. This is closed by
|
||||
default: with `public_note_authors` unset, kinds `1` and `30023` are
|
||||
rejected for everyone, so random notes cannot be spammed to your relay.
|
||||
Every other whitelisted kind (profiles, gift wraps, marketplace listings,
|
||||
lists) flows for everyone as usual, with default-deny by kind still
|
||||
applying underneath.
|
||||
|
||||
```toml
|
||||
[authorization]
|
||||
# hex pubkeys or npubs; unset = closed (no one can post notes/articles)
|
||||
public_note_authors = ["npub1abc...", "fd3a...hex..."]
|
||||
```
|
||||
|
||||
Running a Floonet relay means no public-note spam, and you choose exactly
|
||||
who can post notes and articles. Invalid entries are logged and skipped;
|
||||
the rest still apply.
|
||||
|
||||
## Authentication (NIP-42)
|
||||
|
||||
```toml
|
||||
@@ -149,8 +183,8 @@ Or keep secrets out of the file entirely and use the environment:
|
||||
|
||||
* **`pay_mode = "name"`**: claiming a name answers
|
||||
`402 {"error":"payment_required","pay_url":...}` with a hosted
|
||||
GoblinPay page (GoblinPay, manual slatepack, or a `grin1` address if
|
||||
the operator enabled that method). Once the payment confirms on chain,
|
||||
GoblinPay page (Goblin Wallet over Nostr, or a manual slatepack paste).
|
||||
Once the payment confirms on chain,
|
||||
the same register call succeeds. Clients have everything they need to
|
||||
send the user straight to the pay page and retry.
|
||||
* **`pay_mode = "write"`**: publishing requires a paid admission; the
|
||||
|
||||
+12
-1
@@ -105,7 +105,7 @@ subscriptions_per_min = 30
|
||||
# 24133 remote signing (NIP-46)
|
||||
event_kind_allowlist = [
|
||||
0, 1, 3, 5, 7, 13, 14, 16, 17, 1059, 1111, 10000, 10002, 10050, 24133,
|
||||
27235, 30000, 30003, 30078, 30402, 30405, 30406, 31990,
|
||||
27235, 30000, 30003, 30023, 30078, 30402, 30405, 30406, 31990,
|
||||
]
|
||||
|
||||
# Rejects imprecise requests (kind-only or author-only scrapes).
|
||||
@@ -117,6 +117,17 @@ limit_scrapers = false
|
||||
# "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f",
|
||||
#]
|
||||
|
||||
# Public-note lockdown. The public-note kinds (1 text note, 30023 long-form
|
||||
# article) are accepted ONLY from these authors; every other kind (profiles,
|
||||
# gift wraps, marketplace, lists) is unaffected. Closed by default: leave this
|
||||
# unset and kinds 1/30023 are rejected for everyone, so random notes cannot be
|
||||
# spammed to your relay. Each entry is an npub OR a 32-byte hex pubkey (your
|
||||
# choice); invalid entries are logged and skipped.
|
||||
#public_note_authors = [
|
||||
# "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely",
|
||||
# "a220458190a63c7e1e2462ba8fd56e95ada0233bf216ecaba8e7a057aed9f989",
|
||||
#]
|
||||
|
||||
# Enable NIP-42 authentication (the relay sends an AUTH challenge).
|
||||
#nip42_auth = false
|
||||
|
||||
|
||||
+202
-8
@@ -29,11 +29,15 @@ use crate::event::Event;
|
||||
/// data (NIP-78), 30402 product listing (NIP-99), 30405 product collection,
|
||||
/// 30406 shipping option (Gamma), 31990 handler info (NIP-89), 24133 remote
|
||||
/// signing (NIP-46).
|
||||
pub const DEFAULT_ALLOWED_KINDS: [u64; 23] = [
|
||||
pub const DEFAULT_ALLOWED_KINDS: [u64; 24] = [
|
||||
0, 1, 3, 5, 7, 13, 14, 16, 17, 1059, 1111, 10000, 10002, 10050, 24133,
|
||||
27235, 30000, 30003, 30078, 30402, 30405, 30406, 31990,
|
||||
27235, 30000, 30003, 30023, 30078, 30402, 30405, 30406, 31990,
|
||||
];
|
||||
|
||||
/// The public-note kinds accepted only from authorized authors: 1 (text
|
||||
/// note) and 30023 (long-form article). See [`RestrictedKindAuthors`].
|
||||
pub const LOCKED_KINDS: [u64; 2] = [1, 30023];
|
||||
|
||||
/// Outcome of an admission check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
@@ -121,6 +125,58 @@ impl AdmissionPolicy for PubkeyWhitelist {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict the public-note kinds (1 text note, 30023 long-form article) to
|
||||
/// an operator-configured set of authorized author pubkeys. Closed by
|
||||
/// default: with no authors configured these kinds are rejected for everyone,
|
||||
/// so random notes cannot be spammed to the relay. Every other kind (0
|
||||
/// profiles, 1059 gift wraps, marketplace kinds, lists, ephemeral) is
|
||||
/// completely unaffected. Always installed, so the lockdown holds even when
|
||||
/// the operator left the author list unset.
|
||||
pub struct RestrictedKindAuthors {
|
||||
/// Canonical lowercase-hex author pubkeys allowed to publish locked kinds.
|
||||
authors: Vec<String>,
|
||||
}
|
||||
|
||||
impl RestrictedKindAuthors {
|
||||
/// Build from operator config entries, each an npub or a 32-byte hex
|
||||
/// pubkey (operator's choice). Invalid entries are logged and skipped
|
||||
/// rather than failing the whole relay.
|
||||
fn from_entries(entries: &[String]) -> RestrictedKindAuthors {
|
||||
let mut authors = Vec::new();
|
||||
for entry in entries {
|
||||
let e = entry.trim();
|
||||
if e.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let hex = if crate::utils::is_nip19(e) {
|
||||
crate::utils::nip19_to_hex(e).ok()
|
||||
} else if e.len() == 64 && crate::utils::is_hex(e) {
|
||||
Some(e.to_lowercase())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match hex {
|
||||
Some(h) if h.len() == 64 => authors.push(h.to_lowercase()),
|
||||
_ => tracing::warn!("ignoring invalid public_note_authors entry: {:?}", entry),
|
||||
}
|
||||
}
|
||||
RestrictedKindAuthors { authors }
|
||||
}
|
||||
}
|
||||
|
||||
impl AdmissionPolicy for RestrictedKindAuthors {
|
||||
fn check(&self, event: &Event, _authed_pubkey: Option<&str>) -> Decision {
|
||||
if !LOCKED_KINDS.contains(&event.kind) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
if self.authors.contains(&event.pubkey.to_lowercase()) {
|
||||
Decision::Allow
|
||||
} else {
|
||||
Decision::deny("this relay accepts public notes only from authorized authors")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The composed admission pipeline the server consults.
|
||||
pub struct Admission {
|
||||
policies: Vec<Box<dyn AdmissionPolicy>>,
|
||||
@@ -140,6 +196,17 @@ impl Admission {
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_ALLOWED_KINDS.to_vec());
|
||||
policies.push(Box::new(KindWhitelist { allowed }));
|
||||
// Public-note lockdown: kinds 1 and 30023 only from authorized
|
||||
// authors. Always installed (closed by default) and placed right
|
||||
// after the kind whitelist so a locked kind is decided before auth.
|
||||
let public_note_authors = settings
|
||||
.authorization
|
||||
.public_note_authors
|
||||
.as_deref()
|
||||
.unwrap_or(&[]);
|
||||
policies.push(Box::new(RestrictedKindAuthors::from_entries(
|
||||
public_note_authors,
|
||||
)));
|
||||
// Optional: require NIP-42 auth to write.
|
||||
if settings.authorization.nip42_auth && settings.authorization.require_auth_to_write {
|
||||
policies.push(Box::new(RequireAuth));
|
||||
@@ -185,10 +252,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_whitelist_accepts_allowed_kinds() {
|
||||
let admission = Admission::from_settings(&floonet_settings());
|
||||
// Authorize a known author so the locked public-note kinds (1, 30023)
|
||||
// also pass; this test only exercises the kind whitelist.
|
||||
let author = "aa".repeat(32);
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.public_note_authors = Some(vec![author.clone()]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
for kind in DEFAULT_ALLOWED_KINDS {
|
||||
let mut e = event_of_kind(kind);
|
||||
e.pubkey = author.clone();
|
||||
assert_eq!(
|
||||
admission.check(&event_of_kind(kind), None),
|
||||
admission.check(&e, None),
|
||||
Decision::Allow,
|
||||
"kind {kind} should be allowed"
|
||||
);
|
||||
@@ -199,7 +273,8 @@ mod tests {
|
||||
fn default_whitelist_rejects_disallowed_kinds() {
|
||||
let admission = Admission::from_settings(&floonet_settings());
|
||||
// Common kinds outside the two-app whitelist are NOT accepted.
|
||||
for kind in [4u64, 6, 42, 1984, 9735, 25910, 30017, 30018, 30023] {
|
||||
// (30023 is now whitelisted but author-locked; see the lockdown tests.)
|
||||
for kind in [4u64, 6, 42, 1984, 9735, 25910, 30017, 30018] {
|
||||
match admission.check(&event_of_kind(kind), None) {
|
||||
Decision::Deny { auth_required, .. } => {
|
||||
assert!(!auth_required, "kind rejection is not an auth issue");
|
||||
@@ -215,7 +290,9 @@ mod tests {
|
||||
settings.limits.event_kind_allowlist = None;
|
||||
let admission = Admission::from_settings(&settings);
|
||||
assert_eq!(admission.check(&event_of_kind(1059), None), Decision::Allow);
|
||||
assert_ne!(admission.check(&event_of_kind(30023), None), Decision::Allow);
|
||||
// 9735 is not in the Floonet set, so a missing allowlist must reject
|
||||
// it (proving the fallback is the set, not allow-all).
|
||||
assert_ne!(admission.check(&event_of_kind(9735), None), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -232,7 +309,8 @@ mod tests {
|
||||
let mut settings = floonet_settings();
|
||||
settings.limits.event_kind_allowlist = Some(vec![1, 7]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
assert_eq!(admission.check(&event_of_kind(1), None), Decision::Allow);
|
||||
// Kind 7 is in the custom list and is not author-locked.
|
||||
assert_eq!(admission.check(&event_of_kind(7), None), Decision::Allow);
|
||||
assert_ne!(admission.check(&event_of_kind(0), None), Decision::Allow);
|
||||
}
|
||||
|
||||
@@ -286,9 +364,125 @@ mod tests {
|
||||
let admission = Admission::from_settings(&settings);
|
||||
match admission.check(&event_of_kind(30023), None) {
|
||||
Decision::Deny { auth_required, .. } => {
|
||||
assert!(!auth_required, "disallowed kind must not leak auth hints");
|
||||
assert!(!auth_required, "locked kind must not leak auth hints");
|
||||
}
|
||||
Decision::Allow => panic!("must deny"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public-note lockdown (kinds 1, 30023) ---
|
||||
|
||||
const AUTH_HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const AUTH_NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
||||
|
||||
fn event_from(kind: u64, pubkey: &str) -> Event {
|
||||
let mut e = event_of_kind(kind);
|
||||
e.pubkey = pubkey.to_owned();
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_kinds_closed_by_default() {
|
||||
// No authors configured: kinds 1 and 30023 are rejected for everyone.
|
||||
let admission = Admission::from_settings(&floonet_settings());
|
||||
for kind in LOCKED_KINDS {
|
||||
match admission.check(&event_from(kind, AUTH_HEX), None) {
|
||||
Decision::Deny { auth_required, .. } => assert!(!auth_required),
|
||||
Decision::Allow => panic!("kind {kind} must be closed by default"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_kind_from_unauthorized_key_denied() {
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.public_note_authors = Some(vec![AUTH_HEX.to_owned()]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
let stranger = "bb".repeat(32);
|
||||
for kind in LOCKED_KINDS {
|
||||
assert_ne!(
|
||||
admission.check(&event_from(kind, &stranger), None),
|
||||
Decision::Allow,
|
||||
"kind {kind} from an unauthorized key must be denied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_kind_from_authorized_hex_accepted() {
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.public_note_authors = Some(vec![AUTH_HEX.to_owned()]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
for kind in LOCKED_KINDS {
|
||||
assert_eq!(
|
||||
admission.check(&event_from(kind, AUTH_HEX), None),
|
||||
Decision::Allow,
|
||||
"kind {kind} from the authorized hex key must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_kind_from_authorized_npub_accepted() {
|
||||
// Same key, configured as an npub instead of hex.
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.public_note_authors = Some(vec![AUTH_NPUB.to_owned()]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
for kind in LOCKED_KINDS {
|
||||
assert_eq!(
|
||||
admission.check(&event_from(kind, AUTH_HEX), None),
|
||||
Decision::Allow,
|
||||
"kind {kind} from the authorized npub must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_locked_kinds_unaffected_by_random_keys() {
|
||||
// No authors configured; profiles, gift wraps, and marketplace
|
||||
// listings from arbitrary keys are still accepted (kind 0 stays open).
|
||||
let admission = Admission::from_settings(&floonet_settings());
|
||||
let stranger = "cc".repeat(32);
|
||||
for kind in [0u64, 1059, 30402] {
|
||||
assert_eq!(
|
||||
admission.check(&event_from(kind, &stranger), None),
|
||||
Decision::Allow,
|
||||
"kind {kind} must be unaffected by the public-note lockdown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_authors_skipped_valid_survives() {
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.public_note_authors = Some(vec![
|
||||
"npub1notvalid".to_owned(),
|
||||
"dead".to_owned(),
|
||||
AUTH_HEX.to_owned(),
|
||||
]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
assert_eq!(
|
||||
admission.check(&event_from(1, AUTH_HEX), None),
|
||||
Decision::Allow
|
||||
);
|
||||
assert_ne!(
|
||||
admission.check(&event_from(1, &"bb".repeat(32)), None),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockdown_denies_before_auth_check() {
|
||||
// With auth required, a locked kind from an unauthorized key is denied
|
||||
// by the lockdown (not an auth issue) before the auth gate runs.
|
||||
let mut settings = floonet_settings();
|
||||
settings.authorization.nip42_auth = true;
|
||||
settings.authorization.require_auth_to_write = true;
|
||||
settings.authorization.public_note_authors = Some(vec![AUTH_HEX.to_owned()]);
|
||||
let admission = Admission::from_settings(&settings);
|
||||
match admission.check(&event_from(1, &"bb".repeat(32)), Some(AUTH_HEX)) {
|
||||
Decision::Deny { auth_required, .. } => assert!(!auth_required),
|
||||
Decision::Allow => panic!("must deny unauthorized author"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -82,6 +82,10 @@ pub struct Limits {
|
||||
#[allow(unused)]
|
||||
pub struct Authorization {
|
||||
pub pubkey_whitelist: Option<Vec<String>>, // If present, only allow these pubkeys to publish events
|
||||
// Public-note lockdown: authors (npub or hex) allowed to publish the
|
||||
// locked kinds (1 text note, 30023 long-form article). Closed by default:
|
||||
// unset or empty means those kinds are rejected for everyone.
|
||||
pub public_note_authors: Option<Vec<String>>,
|
||||
pub nip42_auth: bool, // if true enables NIP-42 authentication
|
||||
pub nip42_dms: bool, // if true send DMs only to their authenticated recipients
|
||||
pub require_auth_to_write: bool, // if true (with nip42_auth), only authenticated clients may publish
|
||||
@@ -467,8 +471,9 @@ impl Default for Settings {
|
||||
limit_scrapers: false,
|
||||
},
|
||||
authorization: Authorization {
|
||||
pubkey_whitelist: None, // Allow any address to publish
|
||||
nip42_auth: false, // Disable NIP-42 authentication
|
||||
pubkey_whitelist: None, // Allow any address to publish
|
||||
public_note_authors: None, // Closed by default (kinds 1, 30023)
|
||||
nip42_auth: false, // Disable NIP-42 authentication
|
||||
nip42_dms: false, // Send DMs to everybody
|
||||
require_auth_to_write: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user