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
+10
View File
@@ -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
+36
View File
@@ -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 |
+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"))
+127
View File
@@ -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.)"
+268
View File
@@ -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())