Files
floonet-strfry/plugin/floonet_writepolicy.py
T
2ro 65ec8fcef2 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
2026-07-04 19:58:31 -04:00

406 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""floonet-strfry write policy: the modular event-admission plugin.
strfry streams one JSON request per line on stdin and expects one JSON reply
per line on stdout (see strfry docs/plugins.md). This plugin is the policy
layer of a Floonet relay: strfry core stays stock, and every admission rule
lives here as a small check function.
Checks run in order; the first rejection wins. All checks fail closed: any
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)
3. paid write gate optional; with FLOONET_PAY_MODE=write the AUTHed
pubkey must hold a confirmed payment grant, checked
against the bundled name authority (which talks to
GoblinPay); results are cached for a short TTL
NIP-42/NIP-70 note for checks 2 and 3: stock strfry (pinned ref) issues the
AUTH challenge when a client publishes a NIP-70 protected event (a `-` tag)
and attaches the authed pubkey to protected writes only, after enforcing
author == authed key. So with auth or paid-write enabled, clients publish
their events with a `-` tag: first attempt triggers the challenge, the
client AUTHs, then republishes. Verified end to end against strfry
b80cda3a812af1b662223edad47eb70b053508b6.
This relay serves two apps, so the whitelist is the union of their kinds
(default-deny for everything else). Goblin wallet kinds:
0 profile metadata (NIP-01)
3 contact list (NIP-02)
5 event deletion (NIP-09)
13 seal: inner sealed event of a gift wrap (NIP-59)
1059 gift wrap: sealed DMs and payments (NIP-59)
10002 relay list metadata (NIP-65)
10050 DM relay list (NIP-17)
27235 HTTP auth event for the name authority (NIP-98)
Magick Market marketplace kinds (also reuses 0/5/1059/10002 above):
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)
17 payment receipt / confirmation (Gamma spec)
1111 comment (NIP-22)
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)
30406 shipping option (Gamma spec)
31990 handler information (NIP-89)
24133 NIP-46 remote signing (Nostr Connect, ephemeral — Goblin wallet login)
Excluded on purpose: 25910 (ContextVM) only ever rides inside a 1059 gift
wrap, never raw; 30017/30018 (legacy NIP-15) are read from sellers' own relays
during migration, never written here; 9735 (Lightning zap receipt) is dead in
this GRIN-only fork.
Configuration is environment variables (set them on the strfry process; the
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
enforced by the name authority itself)
FLOONET_AUTHORITY_URL base URL of the bundled name authority
[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
CHECKS. To replace the whole policy: point relay.writePolicy.plugin at your
own executable.
"""
import json
import os
import sys
import time
import urllib.request
DEFAULT_ALLOWED_KINDS = (
"0,1,3,5,7,13,14,16,17,1059,1111,10000,10002,10050,24133,27235,"
"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. 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())
except ValueError:
raise SystemExit(
"floonet-writepolicy: FLOONET_ALLOWED_KINDS must be a comma-"
"separated list of integers, got %r" % kinds_raw
)
if not allowed:
raise SystemExit("floonet-writepolicy: FLOONET_ALLOWED_KINDS is empty")
pay_mode = env.get("FLOONET_PAY_MODE", "off").strip().lower()
if pay_mode not in ("off", "name", "write"):
raise SystemExit(
"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,
"authority_url": env.get(
"FLOONET_AUTHORITY_URL", "http://authority:8191"
).rstrip("/"),
"paid_cache_secs": float(env.get("FLOONET_PAID_CACHE_SECS", "60")),
}
# --- checks (each returns None to pass or a rejection message) ---
def check_kind(req, cfg):
"""The keystone: default-deny kind whitelist. Anything not explicitly
allowed is rejected, including a missing or non-integer kind."""
kind = req.get("event", {}).get("kind")
# bool is an int subclass in Python; a JSON true/false kind is malformed.
if not isinstance(kind, int) or isinstance(kind, bool):
return "blocked: malformed event kind"
if kind not in cfg["allowed_kinds"]:
return "blocked: event kind not accepted by this relay"
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
flow, so presence of a well-formed pubkey is the proof."""
if not cfg["require_auth"]:
return None
authed = req.get("authed")
if not isinstance(authed, str) or len(authed) != 64:
return "auth-required: publish after NIP-42 AUTH"
return None
# paid-status cache: pubkey -> (paid: bool, expires_at: float)
_paid_cache = {}
def _paid_lookup(cfg, pubkey):
"""Ask the bundled name authority whether this pubkey holds a confirmed
write grant. The authority owns the GoblinPay conversation; the plugin
only reads the verdict. Raises on any transport/parse problem."""
url = "%s/api/v1/paid/%s" % (cfg["authority_url"], pubkey)
with urllib.request.urlopen(url, timeout=3) as resp:
body = json.loads(resp.read().decode("utf-8"))
return bool(body.get("paid"))
def check_paid(req, cfg, now=time.monotonic):
"""Optional pay-to-write gate. Requires an AUTHed pubkey (payment grants
are keyed by pubkey), then requires a confirmed grant. Unreachable
authority = reject (fail closed), with a short negative-cache so a dead
authority cannot be hammered once per event."""
if cfg["pay_mode"] != "write":
return None
authed = req.get("authed")
if not isinstance(authed, str) or len(authed) != 64:
return "auth-required: paid publishing needs NIP-42 AUTH"
cached = _paid_cache.get(authed)
t = now()
if cached is not None and cached[1] > t:
paid = cached[0]
else:
try:
paid = _paid_lookup(cfg, authed)
_paid_cache[authed] = (paid, t + cfg["paid_cache_secs"])
except Exception as e:
sys.stderr.write("floonet-writepolicy: paid lookup failed: %s\n" % e)
sys.stderr.flush()
# Negative-cache briefly, then fail closed.
_paid_cache[authed] = (False, t + min(cfg["paid_cache_secs"], 10.0))
return "blocked: payment status unavailable"
if not paid:
return "blocked: payment required to publish on this relay"
return None
CHECKS = [check_kind, check_authorized_authors, check_auth, check_paid]
def decide(req, cfg):
"""Map one plugin request to an accept/reject reply. Fails closed on any
structurally unexpected input rather than trusting it. The checks apply
to every request type: strfry currently only sends type "new" (including
for sync ingest), and checking unconditionally means a future type can
never slip an unwanted event past the policy."""
event = req.get("event")
if not isinstance(event, dict):
return {"id": "", "action": "reject", "msg": "bad event structure"}
event_id = event.get("id")
if not isinstance(event_id, str):
event_id = ""
for check in CHECKS:
try:
msg = check(req, cfg)
except Exception as e:
sys.stderr.write("floonet-writepolicy: %s failed: %s\n" % (check.__name__, e))
sys.stderr.flush()
msg = "policy error"
if msg is not None:
return {"id": event_id, "action": "reject", "msg": msg}
return {"id": event_id, "action": "accept", "msg": ""}
def main():
cfg = load_config()
sys.stderr.write(
"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
# synchronous (strfry blocks waiting for each reply), so the iterator's
# read-ahead buffer must never stall the exchange. Flush every reply.
while True:
line = sys.stdin.readline()
if not line:
break # strfry closed stdin (shutdown/restart); exit cleanly.
line = line.strip()
if not line:
continue
try:
reply = decide(json.loads(line), cfg)
except Exception as e:
# A malformed request must never crash the loop and take the
# relay's write path down with it. Fail closed and log.
sys.stderr.write("floonet-writepolicy: %s\n" % e)
sys.stderr.flush()
reply = {"id": "", "action": "reject", "msg": "policy error"}
sys.stdout.write(json.dumps(reply) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()