Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/tools/sogs-ban.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 15 additions & 2 deletions src/session_ops/sogs/ban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
104 changes: 104 additions & 0 deletions src/session_ops/sogs/names.py
Original file line number Diff line number Diff line change
@@ -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/<token>/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
102 changes: 102 additions & 0 deletions tests/sogs/test_names.py
Original file line number Diff line number Diff line change
@@ -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()
Loading