From 65ec8fcef2c5fddeafac05f56585d1d929dfd784 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:58:31 -0400 Subject: [PATCH] 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 --- .env.example | 10 ++ README.md | 36 +++++ plugin/floonet_writepolicy.py | 164 +++++++++++++++++++- plugin/test_policy.py | 132 +++++++++++++++- scripts/purge_public_notes.sh | 127 ++++++++++++++++ scripts/smoke_test_lockdown.py | 268 +++++++++++++++++++++++++++++++++ 6 files changed, 723 insertions(+), 14 deletions(-) create mode 100755 scripts/purge_public_notes.sh create mode 100644 scripts/smoke_test_lockdown.py diff --git a/.env.example b/.env.example index 80d2113..3100bef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 901294b..bcd813b 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/plugin/floonet_writepolicy.py b/plugin/floonet_writepolicy.py index 01b5768..3bdf85d 100755 --- a/plugin/floonet_writepolicy.py +++ b/plugin/floonet_writepolicy.py @@ -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 diff --git a/plugin/test_policy.py b/plugin/test_policy.py index 85fd409..d9b8ae9 100644 --- a/plugin/test_policy.py +++ b/plugin/test_policy.py @@ -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")) diff --git a/scripts/purge_public_notes.sh b/scripts/purge_public_notes.sh new file mode 100755 index 0000000..4d56cd1 --- /dev/null +++ b/scripts/purge_public_notes.sh @@ -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 --keep [,...] +# [--keep-file ] [--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 ; docker run ... strfry compact data.mdb.bak; docker start +# +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.)" diff --git a/scripts/smoke_test_lockdown.py b/scripts/smoke_test_lockdown.py new file mode 100644 index 0000000..296f4e0 --- /dev/null +++ b/scripts/smoke_test_lockdown.py @@ -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())