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:
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