Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4367644a61 |
+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