relay: lock public notes (kinds 1, 30023) to operator-authorized authors

Public-note kinds are now accepted only from an operator-configured list of
author pubkeys (FLOONET_AUTHORIZED_AUTHORS, hex or npub). Closed by default:
with no authors set, kinds 1 and 30023 are rejected for everyone, so random
notes cannot be spammed to the relay. Every other kind is unaffected and
kind 0 profiles stay open.

- add 30023 (long-form article) to the default kind whitelist
- check_authorized_authors runs right after check_kind; LOCKED_KINDS {1,30023}
- pure-python bech32 npub decoder (no new deps); invalid entries logged/skipped
- load_config also reads a KEY=VALUE floonet.env next to the plugin
  (FLOONET_ENV_FILE), env vars win, so config changes need no container
  recreate; strfry reloads the plugin on mtime change (no restart)
- extend test_policy.py (32 tests green)
- scripts/purge_public_notes.sh (dry-run default) to clean pre-existing notes
- scripts/smoke_test_lockdown.py post-deploy check
- README + .env.example config docs
This commit is contained in:
2ro
2026-07-04 19:58:31 -04:00
parent 36836f9392
commit 65ec8fcef2
6 changed files with 723 additions and 14 deletions
+157 -7
View File
@@ -11,6 +11,10 @@ malformed input, unexpected error, or unreachable dependency rejects the
event rather than letting it through.
1. kind whitelist default-deny; only FLOONET_ALLOWED_KINDS pass
1b. public-note lock kinds 1 and 30023 (text notes, long-form articles)
are accepted only from FLOONET_AUTHORIZED_AUTHORS;
closed by default (no authors = these kinds rejected
for everyone). Every other kind is unaffected.
2. auth requirement optional; with FLOONET_REQUIRE_AUTH=true an event is
rejected unless the connection completed NIP-42 AUTH
(also enable relay.auth in strfry.conf)
@@ -41,7 +45,7 @@ This relay serves two apps, so the whitelist is the union of their kinds
Magick Market marketplace kinds (also reuses 0/5/1059/10002 above):
1 text note: bug reports, shared listings (NIP-01)
1 text note: bug reports, shared listings (NIP-01) [author-locked]
7 reaction (NIP-25)
14 order chat / general order message, plaintext (Gamma spec)
16 order processing and status update (Gamma spec)
@@ -50,6 +54,7 @@ Magick Market marketplace kinds (also reuses 0/5/1059/10002 above):
10000 mute list, used as merchant/product blacklist (NIP-51)
30000 people set: admins, editors, featured users, vanity, NIP-05 (NIP-51)
30003 bookmark set: featured collections (NIP-51)
30023 long-form article: news / posts (NIP-23) [author-locked]
30078 app-specific data: cart, relay prefs, V4V (NIP-78)
30402 product listing (NIP-99)
30405 product collection / featured products (Gamma spec)
@@ -67,6 +72,11 @@ plugin inherits them, e.g. via docker compose or the systemd unit):
FLOONET_ALLOWED_KINDS comma-separated kind whitelist [default: the
Goblin + Magick Market set documented above]
FLOONET_AUTHORIZED_AUTHORS
comma-separated author pubkeys (hex or npub)
allowed to publish the locked public-note kinds
(1, 30023) [default: empty = closed]. Invalid
entries are logged to stderr and skipped.
FLOONET_REQUIRE_AUTH true/false [default: false]
FLOONET_PAY_MODE off|name|write [default: off]
(only "write" changes plugin behavior; "name" is
@@ -75,6 +85,13 @@ plugin inherits them, e.g. via docker compose or the systemd unit):
[default: http://authority:8191]
FLOONET_PAID_CACHE_SECS TTL for cached paid-status lookups [default: 60]
Configuration can also live in a plain KEY=VALUE file next to this script
(floonet.env, path overridable via FLOONET_ENV_FILE) for deployments where
the process environment cannot be changed without recreating the container.
Real environment variables take precedence over the file. strfry reloads the
plugin whenever this script's mtime changes, so `touch`ing the script after
editing floonet.env applies new config with no relay/container restart.
To add a kind: edit FLOONET_ALLOWED_KINDS and restart (or touch the plugin
file; strfry reloads it on mtime change). To add a policy: write a function
`def check_foo(req, cfg): return None or "reject reason"` and append it to
@@ -90,13 +107,110 @@ import urllib.request
DEFAULT_ALLOWED_KINDS = (
"0,1,3,5,7,13,14,16,17,1059,1111,10000,10002,10050,24133,27235,"
"30000,30003,30078,30402,30405,30406,31990"
"30000,30003,30023,30078,30402,30405,30406,31990"
)
# Public-note kinds that are accepted only from authorized authors. Closed by
# default: with no authors configured these kinds are rejected for everyone.
LOCKED_KINDS = frozenset({1, 30023})
# Default path for the optional KEY=VALUE config file, sitting next to this
# script. Used when a deployment cannot change the process environment.
_DEFAULT_ENV_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "floonet.env"
)
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _bech32_polymod(values):
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
def _npub_to_hex(s):
"""Decode a bech32 npub to 64-char lowercase hex, or None if it is not a
structurally valid, checksum-correct 32-byte npub. Pure Python, no deps."""
if s != s.lower() and s != s.upper():
return None # bech32 forbids mixed case
s = s.lower()
pos = s.rfind("1")
if pos < 1 or pos + 7 > len(s):
return None
hrp, data_part = s[:pos], s[pos + 1:]
if hrp != "npub":
return None
try:
data = [_BECH32_CHARSET.index(c) for c in data_part]
except ValueError:
return None
expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
if _bech32_polymod(expanded + data) != 1:
return None
acc = bits = 0
out = bytearray()
for value in data[:-6]: # drop the 6-symbol checksum
acc = (acc << 5) | value
bits += 5
if bits >= 8:
bits -= 8
out.append((acc >> bits) & 0xFF)
if bits >= 5 or (acc & ((1 << bits) - 1)):
return None # leftover padding bits must be zero
if len(out) != 32:
return None
return out.hex()
def _normalize_pubkey(s):
"""Accept a 64-char hex pubkey or an npub; return canonical lowercase hex
or None if the entry is neither."""
s = s.strip()
if len(s) == 64:
try:
int(s, 16)
except ValueError:
return None
return s.lower()
if s.lower().startswith("npub1"):
return _npub_to_hex(s)
return None
def _read_env_file(path):
"""Parse a plain KEY=VALUE file: split on the first '=', strip both sides,
ignore blank and #-comment lines. A missing/unreadable file yields {}."""
values = {}
try:
with open(path, "r", encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
return values
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
values[key.strip()] = val.strip()
return values
def load_config(env=os.environ):
"""Parse plugin configuration from environment variables. Malformed
values fail fast at startup (never silently widen the policy)."""
"""Parse plugin configuration. Values come from the process environment,
optionally backed by a KEY=VALUE file next to this script (real
environment variables take precedence). Malformed kind/pay values fail
fast at startup (never silently widen the policy); malformed authorized
authors are skipped with a stderr log rather than taking the plugin down.
"""
merged = _read_env_file(env.get("FLOONET_ENV_FILE", _DEFAULT_ENV_FILE))
merged.update(env) # real environment variables win over the file
env = merged
kinds_raw = env.get("FLOONET_ALLOWED_KINDS", DEFAULT_ALLOWED_KINDS)
try:
allowed = frozenset(int(k) for k in kinds_raw.split(",") if k.strip())
@@ -113,8 +227,23 @@ def load_config(env=os.environ):
"floonet-writepolicy: FLOONET_PAY_MODE must be off, name or "
"write, got %r" % pay_mode
)
authorized_authors = set()
for entry in env.get("FLOONET_AUTHORIZED_AUTHORS", "").split(","):
entry = entry.strip()
if not entry:
continue
hex_pubkey = _normalize_pubkey(entry)
if hex_pubkey is None:
sys.stderr.write(
"floonet-writepolicy: ignoring invalid authorized author %r\n"
% entry
)
sys.stderr.flush()
continue
authorized_authors.add(hex_pubkey)
return {
"allowed_kinds": allowed,
"authorized_authors": authorized_authors,
"require_auth": env.get("FLOONET_REQUIRE_AUTH", "false").strip().lower()
in ("1", "true", "yes", "on"),
"pay_mode": pay_mode,
@@ -140,6 +269,21 @@ def check_kind(req, cfg):
return None
def check_authorized_authors(req, cfg):
"""Public-note lockdown: the locked kinds (1 text note, 30023 long-form
article) are accepted only from an operator-authorized author pubkey.
Closed by default: with no authors configured these kinds are rejected
for everyone. Every other kind (0 profiles, 1059 gift wraps, marketplace
kinds, lists, ephemeral) is completely unaffected."""
kind = req.get("event", {}).get("kind")
if kind not in LOCKED_KINDS:
return None
pubkey = req.get("event", {}).get("pubkey")
if not isinstance(pubkey, str) or pubkey.lower() not in cfg["authorized_authors"]:
return "blocked: this relay accepts public notes only from authorized authors"
return None
def check_auth(req, cfg):
"""Optional NIP-42 requirement: reject events from connections that have
not completed AUTH. strfry only includes `authed` after a valid kind-22242
@@ -195,7 +339,7 @@ def check_paid(req, cfg, now=time.monotonic):
return None
CHECKS = [check_kind, check_auth, check_paid]
CHECKS = [check_kind, check_authorized_authors, check_auth, check_paid]
def decide(req, cfg):
@@ -225,8 +369,14 @@ def decide(req, cfg):
def main():
cfg = load_config()
sys.stderr.write(
"floonet-writepolicy: allowed kinds %s, require_auth=%s, pay_mode=%s\n"
% (sorted(cfg["allowed_kinds"]), cfg["require_auth"], cfg["pay_mode"])
"floonet-writepolicy: allowed kinds %s, authorized_authors=%d, "
"require_auth=%s, pay_mode=%s\n"
% (
sorted(cfg["allowed_kinds"]),
len(cfg["authorized_authors"]),
cfg["require_auth"],
cfg["pay_mode"],
)
)
sys.stderr.flush()
# Use readline() in a loop rather than iterating stdin: the protocol is
+125 -7
View File
@@ -25,15 +25,18 @@ PLUGIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "floonet_write
PK = "a" * 64
DEFAULT_KINDS = (
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,
)
# npub for PK (the 32-byte key 0xaa..aa); another key for "unauthorized".
PK_NPUB = "npub1424242424242424242424242424242424242424242424242424qamrcaj"
OTHER_PK = "b" * 64
def req(kind, authed=None, event_id="e1"):
def req(kind, authed=None, event_id="e1", pubkey=PK):
"""A request shaped exactly like strfry's plugin input."""
r = {
"type": "new",
"event": {"id": event_id, "pubkey": PK, "kind": kind, "tags": [], "content": ""},
"event": {"id": event_id, "pubkey": pubkey, "kind": kind, "tags": [], "content": ""},
"receivedAt": 1700000000,
"sourceType": "IP4",
"sourceInfo": "203.0.113.7",
@@ -51,8 +54,11 @@ def cfg(**over):
class KindWhitelist(unittest.TestCase):
def test_default_allowed_kinds_accepted(self):
# Authorize PK so the locked public-note kinds (1, 30023) also pass;
# this test only exercises the kind whitelist.
c = cfg(authorized_authors={PK})
for kind in DEFAULT_KINDS:
reply = wp.decide(req(kind), cfg())
reply = wp.decide(req(kind), c)
self.assertEqual(reply["action"], "accept", "kind %d" % kind)
self.assertEqual(reply["id"], "e1")
@@ -60,7 +66,8 @@ class KindWhitelist(unittest.TestCase):
# 25910 (ContextVM) rides inside 1059 gift wraps only;
# 30017/30018 (legacy NIP-15) come from sellers' own relays;
# 9735 (zap) is dead in the GRIN-only fork. All stay rejected.
for kind in (4, 6, 9735, 1058, 1060, 25910, 30017, 30018, 30023, 22242, -1):
# (30023 is now whitelisted but author-locked; see PublicNoteLock.)
for kind in (4, 6, 9735, 1058, 1060, 25910, 30017, 30018, 22242, -1):
reply = wp.decide(req(kind), cfg())
self.assertEqual(reply["action"], "reject", "kind %d" % kind)
self.assertIn("kind not accepted", reply["msg"])
@@ -85,7 +92,10 @@ class KindWhitelist(unittest.TestCase):
self.assertEqual(wp.decide({"event": "nope"}, cfg())["action"], "reject")
def test_custom_kind_list_env(self):
c = wp.load_config(env={"FLOONET_ALLOWED_KINDS": "1,7"})
# Kind 1 is author-locked, so authorize PK to see the whitelist pass.
c = wp.load_config(
env={"FLOONET_ALLOWED_KINDS": "1,7", "FLOONET_AUTHORIZED_AUTHORS": PK}
)
self.assertEqual(wp.decide(req(1), c)["action"], "accept")
self.assertEqual(wp.decide(req(0), c)["action"], "reject")
@@ -194,6 +204,113 @@ class PaidWriteGate(unittest.TestCase):
self.assertIn("kind not accepted", reply["msg"])
class PublicNoteLock(unittest.TestCase):
"""The public-note lockdown: kinds 1 and 30023 are accepted only from
operator-authorized authors; everything else is unaffected."""
def test_locked_kind_from_unauthorized_key_rejected(self):
c = cfg(authorized_authors={PK})
for kind in (1, 30023):
reply = wp.decide(req(kind, pubkey=OTHER_PK), c)
self.assertEqual(reply["action"], "reject", "kind %d" % kind)
self.assertIn("authorized authors", reply["msg"])
def test_locked_kind_from_authorized_hex_key_accepted(self):
c = wp.load_config(env={"FLOONET_AUTHORIZED_AUTHORS": PK})
for kind in (1, 30023):
self.assertEqual(wp.decide(req(kind), c)["action"], "accept", "kind %d" % kind)
def test_locked_kind_from_authorized_npub_accepted(self):
# Same key, configured as an npub instead of hex.
c = wp.load_config(env={"FLOONET_AUTHORIZED_AUTHORS": PK_NPUB})
self.assertEqual(c["authorized_authors"], {PK})
for kind in (1, 30023):
self.assertEqual(wp.decide(req(kind), c)["action"], "accept", "kind %d" % kind)
def test_non_locked_kinds_unaffected_by_random_keys(self):
# No authors configured; profiles, gift wraps and marketplace listings
# from arbitrary keys are still accepted (kind 0 must stay open).
c = cfg()
self.assertEqual(c["authorized_authors"], set())
for kind in (0, 1059, 30402):
self.assertEqual(
wp.decide(req(kind, pubkey=OTHER_PK), c)["action"], "accept", "kind %d" % kind
)
def test_closed_by_default_when_no_authors(self):
c = cfg()
for kind in (1, 30023):
reply = wp.decide(req(kind), c)
self.assertEqual(reply["action"], "reject", "kind %d" % kind)
self.assertIn("authorized authors", reply["msg"])
def test_malformed_npub_skipped_without_crash(self):
# A garbage npub, a too-short hex, and a good hex in one list: the
# good one survives, the bad ones are dropped, the plugin lives.
c = wp.load_config(
env={"FLOONET_AUTHORIZED_AUTHORS": "npub1notvalid,dead,%s" % PK}
)
self.assertEqual(c["authorized_authors"], {PK})
self.assertEqual(wp.decide(req(1), c)["action"], "accept")
def test_mixed_hex_and_npub_and_whitespace(self):
c = wp.load_config(
env={"FLOONET_AUTHORIZED_AUTHORS": " %s , %s " % (PK_NPUB, OTHER_PK)}
)
self.assertEqual(c["authorized_authors"], {PK, OTHER_PK})
class EnvFileConfig(unittest.TestCase):
"""load_config also reads a KEY=VALUE file, with real env taking priority."""
def _write(self, body):
import tempfile
fd, path = tempfile.mkstemp(prefix="floonet-env-")
with os.fdopen(fd, "w") as fh:
fh.write(body)
self.addCleanup(os.remove, path)
return path
def test_env_file_supplies_config(self):
path = self._write(
"# floonet config\n"
"FLOONET_ALLOWED_KINDS = 1,7\n"
"\n"
"FLOONET_AUTHORIZED_AUTHORS=%s\n" % PK
)
c = wp.load_config(env={"FLOONET_ENV_FILE": path})
self.assertEqual(c["allowed_kinds"], frozenset({1, 7}))
self.assertEqual(c["authorized_authors"], {PK})
self.assertEqual(wp.decide(req(1), c)["action"], "accept")
self.assertEqual(wp.decide(req(0), c)["action"], "reject")
def test_real_env_overrides_file(self):
path = self._write("FLOONET_ALLOWED_KINDS=1\n")
c = wp.load_config(
env={"FLOONET_ENV_FILE": path, "FLOONET_ALLOWED_KINDS": "7"}
)
self.assertEqual(c["allowed_kinds"], frozenset({7}))
def test_missing_file_is_harmless(self):
c = wp.load_config(env={"FLOONET_ENV_FILE": "/no/such/floonet.env"})
self.assertIn(1059, c["allowed_kinds"])
class Bech32Decoder(unittest.TestCase):
def test_known_npub_decodes(self):
self.assertEqual(wp._npub_to_hex(PK_NPUB), PK)
def test_invalid_npubs_return_none(self):
for bad in (
"npub1notvalid",
"npub1424242424242424242424242424242424242424242424242424qamrcaX", # bad checksum
"nsec1qqqqq", # wrong hrp
"",
"424242",
):
self.assertIsNone(wp._npub_to_hex(bad), bad)
class StrfryPipeProtocol(unittest.TestCase):
"""Run the plugin as strfry does: one JSONL request per line on stdin,
one JSONL reply per line on stdout, in order."""
@@ -236,7 +353,8 @@ class StrfryPipeProtocol(unittest.TestCase):
def test_env_whitelist_respected_over_the_wire(self):
replies = self.run_plugin(
[req(1, event_id="now-ok")], env={"FLOONET_ALLOWED_KINDS": "1"}
[req(1, event_id="now-ok")],
env={"FLOONET_ALLOWED_KINDS": "1", "FLOONET_AUTHORIZED_AUTHORS": PK},
)
self.assertEqual((replies[0]["id"], replies[0]["action"]), ("now-ok", "accept"))