From 973b0eebe5532e9f7dd4a33806bd9bab534b547a Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 14 Oct 2026 13:18:23 +1100 Subject: [PATCH] feat(sogs): show each account's posted name before a ban sogs-ban now lists every id before the confirmation prompt, and in a dry run, with the display name from its newest post in each room's last 256 messages: "posted as 'X'", "posted, with no display name" or "no recent posts". A room that cannot be read is named under the list. Claude-Session: https://claude.ai/code/session_019cnvrsCXsqLykDyQburD2f --- docs/tools/sogs-ban.md | 14 +++++ src/session_ops/sogs/ban.py | 17 +++++- src/session_ops/sogs/names.py | 104 ++++++++++++++++++++++++++++++++++ tests/sogs/test_names.py | 102 +++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/session_ops/sogs/names.py create mode 100644 tests/sogs/test_names.py diff --git a/docs/tools/sogs-ban.md b/docs/tools/sogs-ban.md index 9c0037f..80959da 100644 --- a/docs/tools/sogs-ban.md +++ b/docs/tools/sogs-ban.md @@ -30,6 +30,20 @@ the deletion has already walked. There is no flag to keep the messages or to narrow the scope: the tool exists for abuse reports, where both are always wanted. +## Who is about to be banned + +Before the prompt, and in a dry run, each id is listed with the display name of its newest +post in our rooms' last 256 messages: + +``` + 05abc...def posted as 'Mallory' + 05123...456 no recent posts +``` + +SOGS stores no profiles, so a name exists only in a message the account posted: an +account that has not posted recently has none to show. A room that cannot be read is +named under the list, since its posters would otherwise read as "no recent posts". + ## Confirming the ban Each step's HTTP status is the confirmation, and the script prints one line per step: diff --git a/src/session_ops/sogs/ban.py b/src/session_ops/sogs/ban.py index 4344a3a..312ec80 100644 --- a/src/session_ops/sogs/ban.py +++ b/src/session_ops/sogs/ban.py @@ -112,6 +112,8 @@ import requests from nacl.signing import SigningKey +from session_ops.sogs import names as sogs_names + try: from session_util import blinding, xed25519 except ImportError as e: # pragma: no cover - environment, not logic @@ -392,6 +394,12 @@ def read_ids(args): return out +def describe_poster(sid, names): + if sid not in names: + return "no recent posts" + return f"posted as {names[sid]!r}" if names[sid] else "posted, with no display name" + + def confirm(assume_yes): """Asks before acting. A closed stdin is a refusal, not a traceback: these run from cron and CI, where the prompt would otherwise fail with an unhandled EOFError.""" @@ -478,10 +486,15 @@ def main(): for step in steps: print(f" {step}") + print() + names, unread = sogs_names.recent_names(client, rooms, ids) + for sid in ids: + print(f" {sid} {describe_poster(sid, names)}") + for token, code in unread: + print(f" (could not read {token}: {code}; its posters are missing above)") + if args.dry_run: print("\nDry run: no ban or deletion was sent.") - for sid in ids: - print(f" {sid}") return 0 if not confirm(args.yes): diff --git a/src/session_ops/sogs/names.py b/src/session_ops/sogs/names.py new file mode 100644 index 0000000..bc9d6cd --- /dev/null +++ b/src/session_ops/sogs/names.py @@ -0,0 +1,104 @@ +""" +The display names accounts last posted under, read from our rooms' recent messages. + +SOGS stores no profiles: a name exists on the server only inside the messages an account +has posted, as the profile its client attaches to each one. An account that has not +posted recently has no name to find. +""" + +from base64 import b64decode + +# Module imports both ways: ban and names import each other at load time. +from session_ops.sogs import ban + +# The most GET /room//messages/recent returns. +RECENT_LIMIT = 256 + +CONTENT_DATA_MESSAGE = 1 +DATA_MESSAGE_PROFILE = 101 +PROFILE_DISPLAY_NAME = 1 + + +def _varint(buf, i): + out = shift = 0 + while True: + b = buf[i] + i += 1 + out |= (b & 0x7F) << shift + shift += 7 + if not b & 0x80: + return out, i + + +def _fields(buf): + """(field number, value) for each field of a protobuf message; bytes for + length-delimited fields, which is all a name lookup descends into.""" + i = 0 + while i < len(buf): + key, i = _varint(buf, i) + number, wire_type = key >> 3, key & 7 + if wire_type == 0: + value, i = _varint(buf, i) + elif wire_type == 1: + value, i = buf[i:i + 8], i + 8 + elif wire_type == 2: + size, i = _varint(buf, i) + value, i = buf[i:i + size], i + size + elif wire_type == 5: + value, i = buf[i:i + 4], i + 4 + else: + raise ValueError(f"unsupported wire type {wire_type}") + yield number, value + + +def _field(buf, number): + return next((v for n, v in _fields(buf) if n == number and isinstance(v, bytes)), None) + + +def display_name(data_b64): + """The display name in a SOGS message's `data`, or None if it carries none or does + not parse.""" + try: + raw = b64decode(data_b64) + except ValueError: + return None + # Clients pad the Content with 0x80 and then zeros. + raw = raw.rstrip(b'\x00') + if raw.endswith(b'\x80'): + raw = raw[:-1] + try: + name = raw + for number in (CONTENT_DATA_MESSAGE, DATA_MESSAGE_PROFILE, PROFILE_DISPLAY_NAME): + name = _field(name, number) + if name is None: + return None + except (IndexError, ValueError): + return None + return name.decode('utf-8', 'replace') or None + + +def recent_names(client, rooms, session_ids): + """Looks each 05 id up in every room's recent messages. + + Returns ({id: name}, [(room token, status)]). An id that posted is in the dict, with + the name from its newest post (None if that post carried none); an id that did not + is absent. The list names the rooms that could not be read, so a missing id is not + mistaken for one that never posted. + """ + owner = {b: sid for sid in session_ids for b in ban.blinded_ids(sid, client.server_pubkey)} + owner.update({sid: sid for sid in session_ids}) + newest, unread = {}, [] + for room in rooms: + token = room['token'] + code, messages = client.request('GET', f'/room/{token}/messages/recent?limit={RECENT_LIMIT}') + if code != 200 or not isinstance(messages, list): + unread.append((token, code)) + continue + for m in messages: + sid = owner.get(m.get('session_id')) + # A deleted message stays in the list with its data nulled. + if not sid or not m.get('data'): + continue + if sid not in newest or m['posted'] > newest[sid][0]: + newest[sid] = (m['posted'], display_name(m['data'])) + return {sid: name for sid, (_, name) in newest.items()}, unread diff --git a/tests/sogs/test_names.py b/tests/sogs/test_names.py new file mode 100644 index 0000000..f1e069f --- /dev/null +++ b/tests/sogs/test_names.py @@ -0,0 +1,102 @@ +""" + uv run python -m unittest tests.sogs.test_names +""" + +import unittest +from base64 import b64encode + +from nacl.signing import SigningKey + +from session_ops.sogs import ban, names +from tests.sogs.test_ban import BLINDED_ABS, SERVER_PUBKEY, SESSION_ID + +OTHER_ID = ban.session_id_of(SigningKey(bytes(range(32)))) + + +def field(number, payload): + return varint(number << 3 | 2) + varint(len(payload)) + payload + + +def varint(n): + out = b'' + while True: + b, n = n & 0x7F, n >> 7 + out += bytes([b | (0x80 if n else 0)]) + if not n: + return out + + +def message_data(name, pad=True): + profile = field(1, name.encode()) + data_message = field(1, b'hello') + field(101, profile) + content = field(1, data_message) + if pad: + content += b'\x80' + b'\x00' * (160 - len(content) - 1) + return b64encode(content).decode() + + +def post(sid, posted, data): + return {'session_id': sid, 'posted': posted, 'data': data} + + +class FakeClient: + def __init__(self, by_room): + self.server_pubkey = bytes.fromhex(SERVER_PUBKEY) + self.by_room = by_room + + def request(self, method, path, body_json=None): + token = path.split('/')[2] + return self.by_room[token] + + +class TestDisplayName(unittest.TestCase): + def test_reads_the_name_from_padded_content(self): + self.assertEqual(names.display_name(message_data('Mallory')), 'Mallory') + + def test_reads_it_without_padding(self): + self.assertEqual(names.display_name(message_data('Mallory', pad=False)), 'Mallory') + + def test_content_without_a_profile_has_no_name(self): + content = field(1, field(1, b'hello')) + self.assertIsNone(names.display_name(b64encode(content).decode())) + + def test_garbage_is_no_name_rather_than_an_error(self): + self.assertIsNone(names.display_name(b64encode(b'\x0a\xff\xff').decode())) + self.assertIsNone(names.display_name('not base64!')) + + +class TestRecentNames(unittest.TestCase): + def test_the_newest_post_names_the_account(self): + client = FakeClient({ + 'a': (200, [post(BLINDED_ABS, 10, message_data('Old')), post(BLINDED_ABS, 30, message_data('New'))]), + 'b': (200, [post(BLINDED_ABS, 20, message_data('Middle'))]), + }) + found, unread = names.recent_names(client, [{'token': 'a'}, {'token': 'b'}], [SESSION_ID]) + self.assertEqual(found, {SESSION_ID: 'New'}) + self.assertEqual(unread, []) + + def test_an_account_that_did_not_post_is_absent(self): + client = FakeClient({'a': (200, [post(BLINDED_ABS, 10, message_data('Someone'))])}) + found, _ = names.recent_names(client, [{'token': 'a'}], [OTHER_ID]) + self.assertEqual(found, {}) + + def test_a_deleted_post_is_skipped(self): + client = FakeClient({'a': (200, [post(BLINDED_ABS, 10, None)])}) + found, _ = names.recent_names(client, [{'token': 'a'}], [SESSION_ID]) + self.assertEqual(found, {}) + + def test_an_unreadable_room_is_reported(self): + client = FakeClient({'a': (403, 'nope'), 'b': (200, [])}) + _, unread = names.recent_names(client, [{'token': 'a'}, {'token': 'b'}], [SESSION_ID]) + self.assertEqual(unread, [('a', 403)]) + + +class TestDescribePoster(unittest.TestCase): + def test_each_case_reads_differently(self): + self.assertEqual(ban.describe_poster(SESSION_ID, {}), "no recent posts") + self.assertEqual(ban.describe_poster(SESSION_ID, {SESSION_ID: None}), "posted, with no display name") + self.assertEqual(ban.describe_poster(SESSION_ID, {SESSION_ID: 'Mallory'}), "posted as 'Mallory'") + + +if __name__ == '__main__': + unittest.main()