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:
@@ -37,6 +37,16 @@ FLOONET_RELAYS=wss://floonet.example
|
||||
# To accept another kind, add it here and restart the relay.
|
||||
FLOONET_ALLOWED_KINDS=0,3,5,13,1059,10002,10050,27235
|
||||
|
||||
# --- Public-note lockdown (kinds 1, 30023) ---
|
||||
|
||||
# Public notes (kind 1 text notes, kind 30023 long-form articles) are
|
||||
# accepted ONLY from the authors listed here. Everything else (profiles,
|
||||
# gift wraps, marketplace kinds, lists) is unaffected. Closed by default:
|
||||
# leave this empty and kinds 1/30023 are rejected for everyone, so random
|
||||
# notes cannot be spammed to your relay. Comma-separated, each entry a hex
|
||||
# pubkey OR an npub (your choice); invalid entries are logged and skipped.
|
||||
FLOONET_AUTHORIZED_AUTHORS=
|
||||
|
||||
# --- Authentication (optional) ---
|
||||
|
||||
# Require NIP-42 AUTH before accepting writes. Set to true AND flip
|
||||
|
||||
@@ -78,6 +78,40 @@ set is exactly what the Goblin wallet uses:
|
||||
To accept another kind, edit `FLOONET_ALLOWED_KINDS` in `.env` and restart
|
||||
the relay. Nothing else changes.
|
||||
|
||||
## Public notes are author-locked
|
||||
|
||||
Public-note kinds (`1` text notes, `30023` long-form articles) are accepted
|
||||
**only** from an operator-chosen list of authors. This is closed by default:
|
||||
with no authors configured, kinds `1` and `30023` are rejected for everyone,
|
||||
so random notes cannot be spammed to your relay. Everything else (profiles,
|
||||
gift wraps, marketplace listings, lists, ephemeral events) is unaffected, and
|
||||
kind `0` profiles stay open so wallets can republish them.
|
||||
|
||||
You decide who can post. List the authors in `FLOONET_AUTHORIZED_AUTHORS`,
|
||||
comma-separated, each entry a hex pubkey or an npub (your choice):
|
||||
|
||||
```sh
|
||||
FLOONET_AUTHORIZED_AUTHORS=npub1abc...,fd3a...hex...,npub1def...
|
||||
```
|
||||
|
||||
Invalid entries are logged to stderr and skipped; the rest still apply.
|
||||
|
||||
### Changing authors without recreating the container
|
||||
|
||||
Where the container's environment cannot be changed without recreating it,
|
||||
drop a plain `KEY=VALUE` file named `floonet.env` next to the plugin script
|
||||
(override the path with `FLOONET_ENV_FILE`) and set the same keys there:
|
||||
|
||||
```sh
|
||||
# /usr/local/bin/floonet.env
|
||||
FLOONET_AUTHORIZED_AUTHORS=npub1abc...,npub1def...
|
||||
```
|
||||
|
||||
Real environment variables take precedence over the file. strfry reloads the
|
||||
plugin whenever the script's modification time changes, so after editing
|
||||
`floonet.env` just `touch` the plugin script and the next write picks up the
|
||||
new list. No relay or container restart is needed.
|
||||
|
||||
## Authentication (NIP-42), optional
|
||||
|
||||
Set `FLOONET_REQUIRE_AUTH=true` in `.env` and flip `relay.auth.enabled` to
|
||||
@@ -258,6 +292,8 @@ essentials:
|
||||
| `FLOONET_BASE_URL` | `https://floonet.example` | public base URL (NIP-98 verification) |
|
||||
| `FLOONET_RELAYS` | `wss://floonet.example` | relays advertised in nostr.json |
|
||||
| `FLOONET_ALLOWED_KINDS` | `0,3,5,13,1059,10002,10050,27235` | the whitelist |
|
||||
| `FLOONET_AUTHORIZED_AUTHORS` | unset (closed) | authors (hex or npub) allowed to post kinds `1`/`30023` |
|
||||
| `FLOONET_ENV_FILE` | `floonet.env` next to the plugin | optional `KEY=VALUE` config file (env vars win) |
|
||||
| `FLOONET_REQUIRE_AUTH` | `false` | NIP-42 gate |
|
||||
| `FLOONET_PAY_MODE` | `off` | `off` / `name` / `write` |
|
||||
| `FLOONET_NAME_PRICE_GRIN` | `0` | price of a name, in GRIN |
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# purge_public_notes.sh - remove already-stored public notes (kinds 1 and
|
||||
# 30023) that were NOT written by an authorized author, so enabling the
|
||||
# public-note lockdown also cleans up what slipped in before it.
|
||||
#
|
||||
# It runs entirely through `docker exec` against a running strfry container
|
||||
# and NEVER restarts, stops, or recreates anything.
|
||||
#
|
||||
# What it does:
|
||||
# 1. `strfry scan '{"kinds":[1,30023]}'` (all stored public notes)
|
||||
# 2. filter OUT events whose pubkey is in the keep-list (jq)
|
||||
# 3. collect the ids of everything that remains
|
||||
# 4. delete them in chunks via `strfry delete --filter '{"ids":[...]}'`
|
||||
#
|
||||
# Default mode is a DRY RUN: it only prints counts and deletes nothing. Pass
|
||||
# --execute to actually delete.
|
||||
#
|
||||
# Usage:
|
||||
# ./purge_public_notes.sh --container <name> --keep <hexpubkey>[,<hexpubkey>...]
|
||||
# [--keep-file <path>] [--chunk N] [--execute]
|
||||
#
|
||||
# --container the strfry container name (required)
|
||||
# --keep comma-separated hex pubkeys to PRESERVE (the authorized
|
||||
# authors). npubs are NOT accepted here; convert first.
|
||||
# --keep-file optional file with one hex pubkey per line (# comments ok),
|
||||
# merged with --keep
|
||||
# --chunk ids per delete call (default 500)
|
||||
# --execute actually delete (otherwise dry run)
|
||||
#
|
||||
# NOTE on reclaiming space: deletes mark records free but do not shrink the
|
||||
# LMDB file. `strfry compact` reclaims fragmentation, but it REQUIRES stopping
|
||||
# strfry first, which is out of scope for this script (we never stop anything).
|
||||
# Run compaction separately during a maintenance window if you need the space:
|
||||
# docker stop <name>; docker run ... strfry compact data.mdb.bak; docker start <name>
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER=""
|
||||
KEEP_CSV=""
|
||||
KEEP_FILE=""
|
||||
CHUNK=500
|
||||
EXECUTE=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--container) CONTAINER="$2"; shift 2 ;;
|
||||
--keep) KEEP_CSV="$2"; shift 2 ;;
|
||||
--keep-file) KEEP_FILE="$2"; shift 2 ;;
|
||||
--chunk) CHUNK="$2"; shift 2 ;;
|
||||
--execute) EXECUTE=1; shift ;;
|
||||
--dry-run) EXECUTE=0; shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$CONTAINER" ] || { echo "error: --container is required" >&2; exit 2; }
|
||||
|
||||
# Build the keep-list (one hex pubkey per line) from --keep and --keep-file.
|
||||
KEEP_LIST="$(mktemp)"
|
||||
RAW_FILE="$(mktemp)"
|
||||
IDS_FILE="$(mktemp)"
|
||||
CHUNKS_FILE="$(mktemp)"
|
||||
trap 'rm -f "$KEEP_LIST" "$RAW_FILE" "$IDS_FILE" "$CHUNKS_FILE" 2>/dev/null || true' EXIT
|
||||
if [ -n "$KEEP_CSV" ]; then
|
||||
printf '%s\n' "$KEEP_CSV" | tr ',' '\n'
|
||||
fi >> "$KEEP_LIST"
|
||||
if [ -n "$KEEP_FILE" ]; then
|
||||
grep -v '^[[:space:]]*#' "$KEEP_FILE" 2>/dev/null || true
|
||||
fi >> "$KEEP_LIST"
|
||||
# Normalise: trim, lowercase, drop blanks, keep only 64-char hex, de-dup.
|
||||
KEEP_CLEAN="$(mktemp)"
|
||||
tr 'A-Z' 'a-z' < "$KEEP_LIST" | tr -d ' \t\r' \
|
||||
| grep -E '^[0-9a-f]{64}$' | sort -u > "$KEEP_CLEAN" || true
|
||||
mv "$KEEP_CLEAN" "$KEEP_LIST"
|
||||
KEEP_COUNT="$(wc -l < "$KEEP_LIST" | tr -d ' ')"
|
||||
|
||||
echo "container: $CONTAINER"
|
||||
echo "keep-list: $KEEP_COUNT authorized pubkey(s)"
|
||||
echo "mode: $([ "$EXECUTE" -eq 1 ] && echo EXECUTE || echo 'DRY RUN (no deletes)')"
|
||||
|
||||
# jq program: emit the .id of every scanned event whose .pubkey is not in the
|
||||
# keep-list. The keep-list is passed as a newline string and split into a set.
|
||||
JQ_PROG='($keep | split("\n") | map(select(length>0))) as $k
|
||||
| select((.pubkey // "") as $p | ($k | index($p)) | not)
|
||||
| .id'
|
||||
|
||||
# Scan once into a file, then derive both counts from it (no second scan).
|
||||
docker exec -i "$CONTAINER" strfry scan '{"kinds":[1,30023]}' > "$RAW_FILE" || true
|
||||
jq -r --arg keep "$(cat "$KEEP_LIST")" "$JQ_PROG" < "$RAW_FILE" \
|
||||
| grep -E '^[0-9a-f]{64}$' | sort -u > "$IDS_FILE" || true
|
||||
|
||||
TOTAL_SCANNED="$(grep -c . "$RAW_FILE" || echo 0)"
|
||||
TO_DELETE="$(wc -l < "$IDS_FILE" | tr -d ' ')"
|
||||
echo "stored public notes (kinds 1,30023): $TOTAL_SCANNED"
|
||||
echo "to delete (not from an authorized author): $TO_DELETE"
|
||||
|
||||
if [ "$TO_DELETE" -eq 0 ]; then
|
||||
echo "nothing to delete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$EXECUTE" -ne 1 ]; then
|
||||
echo "dry run: pass --execute to delete these $TO_DELETE event(s)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Delete in chunks so the ids filter stays a sane size. Each chunk is one
|
||||
# compact JSON object per line: {"ids":[...]}.
|
||||
jq -R -s -c --argjson chunk "$CHUNK" '
|
||||
split("\n") | map(select(length>0))
|
||||
| [range(0; length; $chunk) as $i | .[$i:$i+$chunk]]
|
||||
| .[] | {ids: .}
|
||||
' "$IDS_FILE" > "$CHUNKS_FILE"
|
||||
|
||||
deleted=0
|
||||
while IFS= read -r chunk_json; do
|
||||
[ -n "$chunk_json" ] || continue
|
||||
docker exec -i "$CONTAINER" strfry delete --filter "$chunk_json"
|
||||
n="$(printf '%s' "$chunk_json" | jq '.ids | length')"
|
||||
deleted=$((deleted + n))
|
||||
echo "deleted chunk of $n (running total $deleted / $TO_DELETE)"
|
||||
done < "$CHUNKS_FILE"
|
||||
|
||||
echo "done: deleted $deleted event(s). (Space is not reclaimed until a"
|
||||
echo "separate 'strfry compact' during a maintenance window; see header.)"
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-deploy smoke test for the public-note lockdown.
|
||||
|
||||
Signs two throwaway events with a fresh (unauthorized) key and publishes them
|
||||
to a live relay, then checks the relay's OK responses:
|
||||
|
||||
* kind 1 (text note) -> expected BLOCKED (author not authorized)
|
||||
* kind 0 (profile) -> expected ACCEPTED (profiles stay open)
|
||||
|
||||
Exit 0 only if both expectations hold. Zero third-party dependencies: a
|
||||
compact BIP-340 Schnorr signer and a minimal RFC-6455 client, both stdlib.
|
||||
|
||||
Usage:
|
||||
./smoke_test_lockdown.py wss://relay.example.com
|
||||
./smoke_test_lockdown.py wss://relay.example.com --insecure # skip TLS verify
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# --- BIP-340 Schnorr (secp256k1), reference-style, stdlib only ---------------
|
||||
|
||||
_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
_G = (
|
||||
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
|
||||
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
|
||||
)
|
||||
|
||||
|
||||
def _tagged_hash(tag, msg):
|
||||
t = hashlib.sha256(tag.encode()).digest()
|
||||
return hashlib.sha256(t + t + msg).digest()
|
||||
|
||||
|
||||
def _inv(x):
|
||||
return pow(x, _P - 2, _P)
|
||||
|
||||
|
||||
def _point_add(a, b):
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
if a[0] == b[0] and (a[1] != b[1]):
|
||||
return None
|
||||
if a == b:
|
||||
lam = (3 * a[0] * a[0] * _inv(2 * a[1])) % _P
|
||||
else:
|
||||
lam = ((b[1] - a[1]) * _inv(b[0] - a[0])) % _P
|
||||
x = (lam * lam - a[0] - b[0]) % _P
|
||||
y = (lam * (a[0] - x) - a[1]) % _P
|
||||
return (x, y)
|
||||
|
||||
|
||||
def _point_mul(point, k):
|
||||
r = None
|
||||
while k:
|
||||
if k & 1:
|
||||
r = _point_add(r, point)
|
||||
point = _point_add(point, point)
|
||||
k >>= 1
|
||||
return r
|
||||
|
||||
|
||||
def _has_even_y(point):
|
||||
return point[1] % 2 == 0
|
||||
|
||||
|
||||
def _lift_x(x):
|
||||
y_sq = (pow(x, 3, _P) + 7) % _P
|
||||
y = pow(y_sq, (_P + 1) // 4, _P)
|
||||
if pow(y, 2, _P) != y_sq:
|
||||
return None
|
||||
return (x, y if y % 2 == 0 else _P - y)
|
||||
|
||||
|
||||
def pubkey_xonly(seckey):
|
||||
d0 = int.from_bytes(seckey, "big")
|
||||
p = _point_mul(_G, d0)
|
||||
return p[0].to_bytes(32, "big")
|
||||
|
||||
|
||||
def schnorr_sign(msg32, seckey, aux=b"\x00" * 32):
|
||||
d0 = int.from_bytes(seckey, "big")
|
||||
p = _point_mul(_G, d0)
|
||||
d = d0 if _has_even_y(p) else _N - d0
|
||||
t = (d ^ int.from_bytes(_tagged_hash("BIP0340/aux", aux), "big")).to_bytes(32, "big")
|
||||
px = p[0].to_bytes(32, "big")
|
||||
rand = _tagged_hash("BIP0340/nonce", t + px + msg32)
|
||||
k0 = int.from_bytes(rand, "big") % _N
|
||||
r = _point_mul(_G, k0)
|
||||
k = k0 if _has_even_y(r) else _N - k0
|
||||
rx = r[0].to_bytes(32, "big")
|
||||
e = int.from_bytes(_tagged_hash("BIP0340/challenge", rx + px + msg32), "big") % _N
|
||||
return rx + ((k + e * d) % _N).to_bytes(32, "big")
|
||||
|
||||
|
||||
# --- Nostr event ------------------------------------------------------------
|
||||
|
||||
|
||||
def make_signed_event(seckey, kind, content, tags=None):
|
||||
tags = tags or []
|
||||
pubkey = pubkey_xonly(seckey).hex()
|
||||
created_at = int(time.time())
|
||||
serial = json.dumps(
|
||||
[0, pubkey, created_at, kind, tags, content],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
eid = hashlib.sha256(serial.encode()).digest()
|
||||
sig = schnorr_sign(eid, seckey)
|
||||
return {
|
||||
"id": eid.hex(),
|
||||
"pubkey": pubkey,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": sig.hex(),
|
||||
}
|
||||
|
||||
|
||||
# --- Minimal RFC-6455 client (text frames only) -----------------------------
|
||||
|
||||
|
||||
class WS:
|
||||
def __init__(self, url, insecure=False, timeout=15):
|
||||
u = urlparse(url)
|
||||
secure = u.scheme == "wss"
|
||||
host = u.hostname
|
||||
port = u.port or (443 if secure else 80)
|
||||
path = u.path or "/"
|
||||
raw = socket.create_connection((host, port), timeout=timeout)
|
||||
if secure:
|
||||
ctx = ssl.create_default_context()
|
||||
if insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
raw = ctx.wrap_socket(raw, server_hostname=host)
|
||||
self.sock = raw
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
handshake = (
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n" % (path, host, key)
|
||||
)
|
||||
self.sock.sendall(handshake.encode())
|
||||
resp = self._read_until(b"\r\n\r\n")
|
||||
if b"101" not in resp.split(b"\r\n", 1)[0]:
|
||||
raise RuntimeError("websocket upgrade failed: %r" % resp[:120])
|
||||
self._buf = b""
|
||||
|
||||
def _read_until(self, marker):
|
||||
data = b""
|
||||
while marker not in data:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
def send_text(self, text):
|
||||
payload = text.encode()
|
||||
header = bytearray([0x81]) # FIN + text opcode
|
||||
mask = os.urandom(4)
|
||||
n = len(payload)
|
||||
if n < 126:
|
||||
header.append(0x80 | n)
|
||||
elif n < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header += n.to_bytes(2, "big")
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header += n.to_bytes(8, "big")
|
||||
header += mask
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
self.sock.sendall(bytes(header) + masked)
|
||||
|
||||
def _recv_exact(self, n):
|
||||
while len(self._buf) < n:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise RuntimeError("connection closed")
|
||||
self._buf += chunk
|
||||
out, self._buf = self._buf[:n], self._buf[n:]
|
||||
return out
|
||||
|
||||
def recv_text(self):
|
||||
b0, b1 = self._recv_exact(2)
|
||||
opcode = b0 & 0x0F
|
||||
length = b1 & 0x7F
|
||||
if length == 126:
|
||||
length = int.from_bytes(self._recv_exact(2), "big")
|
||||
elif length == 127:
|
||||
length = int.from_bytes(self._recv_exact(8), "big")
|
||||
payload = self._recv_exact(length) if length else b""
|
||||
if opcode == 0x8: # close
|
||||
raise RuntimeError("server closed the connection")
|
||||
if opcode in (0x9, 0xA): # ping/pong: ignore, read next
|
||||
return self.recv_text()
|
||||
return payload.decode("utf-8", "replace")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def publish_expect(ws, event, want_accept, timeout=15):
|
||||
"""Send one event, wait for its OK frame, return (accepted, message)."""
|
||||
ws.send_text(json.dumps(["EVENT", event]))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
msg = json.loads(ws.recv_text())
|
||||
if msg[0] == "OK" and msg[1] == event["id"]:
|
||||
return bool(msg[2]), (msg[3] if len(msg) > 3 else "")
|
||||
raise RuntimeError("no OK response for event %s" % event["id"])
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
insecure = "--insecure" in sys.argv
|
||||
if not args:
|
||||
sys.stderr.write("usage: smoke_test_lockdown.py wss://relay [--insecure]\n")
|
||||
return 2
|
||||
url = args[0]
|
||||
seckey = os.urandom(32)
|
||||
note = make_signed_event(seckey, 1, "floonet lockdown smoke test; please ignore")
|
||||
profile = make_signed_event(seckey, 0, json.dumps({"name": "floonet-smoke"}))
|
||||
|
||||
print("relay: %s" % url)
|
||||
print("throwaway: %s (unauthorized by design)" % pubkey_xonly(seckey).hex())
|
||||
|
||||
ws = WS(url, insecure=insecure)
|
||||
ok = True
|
||||
try:
|
||||
accepted, why = publish_expect(ws, note, want_accept=False)
|
||||
good = not accepted
|
||||
ok = ok and good
|
||||
print("kind 1 note: %s (expected BLOCKED) msg=%r"
|
||||
% ("BLOCKED" if not accepted else "ACCEPTED", why))
|
||||
if accepted:
|
||||
print(" FAIL: an unauthorized author's text note was accepted")
|
||||
|
||||
accepted, why = publish_expect(ws, profile, want_accept=True)
|
||||
ok = ok and accepted
|
||||
print("kind 0 profile: %s (expected ACCEPTED) msg=%r"
|
||||
% ("ACCEPTED" if accepted else "BLOCKED", why))
|
||||
if not accepted:
|
||||
print(" FAIL: a profile was blocked; kind 0 must stay open")
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
print("RESULT: %s" % ("PASS" if ok else "FAIL"))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user