Add conflict resolution for multiple users using the same key. Fixes #1.

This commit is contained in:
s3lph 2022-04-18 21:42:31 +02:00
parent e33692f40a
commit 4b9dfa550a
8 changed files with 360 additions and 74 deletions

View file

@ -16,16 +16,17 @@ from datetime import datetime
import pgpy # type: ignore
from multischleuder.reporting import ConflictMessage, Message
from multischleuder.reporting import KeyConflictMessage, Message, UserConflictMessage
from multischleuder.types import SchleuderKey, SchleuderSubscriber
class KeyConflictResolution:
def __init__(self, interval: int, statefile: str, template: str):
def __init__(self, interval: int, statefile: str, key_template: str, user_template: str):
self._interval: int = interval
self._state_file: str = statefile
self._template: str = template
self._key_template: str = key_template
self._user_template: str = user_template
self._dry_run: bool = False
self._logger: logging.Logger = logging.getLogger()
@ -36,25 +37,41 @@ class KeyConflictResolution:
target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Optional[Message]]]:
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
conflicts: List[Optional[Message]] = []
# First check for keys that are being used by more than one subscriber
keys: Dict[str, List[SchleuderSubscriber]] = {}
for s in subscriptions:
if s.key is None:
continue
keys.setdefault(s.key.fingerprint, []).append(s)
key_resolved: List[SchleuderSubscriber] = []
for k in keys.values():
rk, m = self._resolve_users(target, mail_from, k)
if rk is not None:
key_resolved.append(rk)
if m is not None:
conflicts.extend(m)
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
# Only consider subscribers who have not been removed due to a key used by multiple users
for s in key_resolved:
subs.setdefault(s.email, []).append(s)
# Perform conflict resolution for each set of subscribers with the same email
resolved: List[SchleuderSubscriber] = []
conflicts: List[Optional[Message]] = []
for c in subs.values():
r, m = self._resolve(target, mail_from, c)
r, msg = self._resolve_keys(target, mail_from, c)
if r is not None:
resolved.append(r)
if m is not None:
conflicts.append(m)
if msg is not None:
conflicts.append(msg)
return resolved, conflicts
def _resolve(self,
target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) \
-> Tuple[Optional[SchleuderSubscriber], Optional[ConflictMessage]]:
def _resolve_keys(self,
target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) \
-> Tuple[Optional[SchleuderSubscriber], Optional[Message]]:
notnull = [s for s in subscriptions if s.key is not None]
if len(notnull) == 0:
return None, None
@ -69,21 +86,58 @@ class KeyConflictResolution:
fpr = 'no key' if s.key is None else s.key.fingerprint
self._logger.debug(f' - {s.schleuder}: {fpr}')
# Generate a SHA1 digest that only changes when the subscription list changes
digest = self._make_digest(earliest, subscriptions)
msg: Optional[ConflictMessage] = None
digest = self._make_key_digest(earliest, subscriptions)
msg: Optional[Message] = None
# Create a conflict message only if it hasn't been sent recently
if self._should_send(digest):
msg = ConflictMessage(
msg = KeyConflictMessage(
target,
earliest,
subscriptions,
digest,
mail_from,
self._template
self._key_template
)
# Return the result of conflict resolution
return earliest, msg
def _resolve_users(self,
target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) \
-> Tuple[Optional[SchleuderSubscriber], Optional[List[Message]]]:
notnull = [s for s in subscriptions if s.key is not None]
if len(notnull) == 0:
return None, None
emails = {s.email for s in notnull}
if len(emails) == 1:
# No conflict if all emails are the same
return notnull[0], None
# Conflict Resolution: Choose the OLDEST subscription with a key, but notify using ALL recipients
earliest: SchleuderSubscriber = min(notnull, key=lambda x: x.created_at)
assert earliest.key is not None # Make mypy happy; it can't know that earliest.key can't be None
self._logger.debug(f'User Conflict for {earliest.key.fingerprint} in lists, chose {earliest.email}:')
for s in subscriptions:
self._logger.debug(f' - {s.schleuder}: {s.email}')
# Generate a SHA1 digest that only changes when the subscription list changes
digest = self._make_user_digest(earliest, subscriptions)
msgs: Optional[List[Message]] = None
# Create a conflict message only if it hasn't been sent recently
if self._should_send(digest):
msgs = []
for email in emails:
msgs.append(UserConflictMessage(
target,
email,
earliest,
subscriptions,
digest,
mail_from,
self._user_template
))
# Return the result of conflict resolution
return earliest, msgs
def _should_send(self, digest: str) -> bool:
now = int(datetime.utcnow().timestamp())
try:
@ -114,7 +168,7 @@ class KeyConflictResolution:
self._logger.exception('Cannot open or write statefile. Not sending any messages!')
return False
def _make_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
def _make_key_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
# Sort so the hash stays the same if the set of subscriptions is the same.
# There is no guarantee that the subs are in any specific order.
subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder)
@ -130,3 +184,19 @@ class KeyConflictResolution:
key = s.key.blob.encode()
h.update(struct.pack('!ds', s.schleuder, key))
return h.hexdigest()
def _make_user_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
# Sort so the hash stays the same if the set of subscriptions is the same.
# There is no guarantee that the subs are in any specific order.
subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder)
h = hashlib.new('sha1')
assert chosen.key is not None # Make mypy happy; it can't know that chosen.key can't be None
# Include the chosen email an source sub-list
h.update(struct.pack('!ssd',
chosen.key.fingerprint.encode(),
chosen.email.encode(),
chosen.schleuder))
# Include all subscriptions, including the FULL key
for s in subs:
h.update(struct.pack('!ds', s.schleuder, s.email.encode()))
return h.hexdigest()

View file

@ -83,7 +83,7 @@ class Message(abc.ABC):
return mp0
class ConflictMessage(Message):
class KeyConflictMessage(Message):
def __init__(self,
schleuder: str,
@ -119,6 +119,43 @@ class ConflictMessage(Message):
self.mime['X-MultiSchleuder-Digest'] = digest
class UserConflictMessage(Message):
def __init__(self,
schleuder: str,
subscriber: str,
chosen: SchleuderSubscriber,
affected: List[SchleuderSubscriber],
digest: str,
mail_from: str,
template: str):
# Render the message body
assert chosen.key is not None
_chosen = chosen.email
_affected = ''
for a in affected:
_affected += f'{a.email} {a.schleuder}\n'
content = template.format(
subscriber=subscriber,
fingerprint=chosen.key.fingerprint,
schleuder=schleuder,
chosen=_chosen,
affected=_affected
)
self._chosen: SchleuderSubscriber = chosen
self._affected: List[SchleuderSubscriber] = affected
self._template: str = template
super().__init__(
schleuder=schleuder,
mail_from=mail_from,
mail_to=chosen.email,
content=content,
encrypt_to=[chosen.key.blob]
)
self.mime['Subject'] = f'MultiSchleuder {self._schleuder} - Subscriber Conflict'
self.mime['X-MultiSchleuder-Digest'] = digest
class AdminReport(Message):
def __init__(self,
@ -194,7 +231,9 @@ class Reporter:
def add_message(self, message: Optional[Message]):
if message is None:
return
if not self._send_conflict_messages and isinstance(message, ConflictMessage):
if not self._send_conflict_messages and isinstance(message, KeyConflictMessage):
return
if not self._send_conflict_messages and isinstance(message, UserConflictMessage):
return
if not self._send_admin_reports and isinstance(message, AdminReport):
return

View file

@ -13,7 +13,8 @@ api:
conflict:
interval: 3600
statefile: /tmp/state.json
template: ''
key_template: ''
user_template: ''
'''
@ -34,13 +35,20 @@ smtp:
conflict:
interval: 3600
statefile: /tmp/state.json
template: |
key_template: |
Dear {subscriber}
This is a test and should not be used in production:
{affected}
If you ever receive this text via email, notify your admin:
{chosen}
Regards, {schleuder}
user_template: |
Dear {subscriber}
This is a test and should not be used in production:
{affected}
If you ever receive this text via email, notify your admin:
{chosen}
Kind regards, {schleuder}
lists:
@ -97,7 +105,8 @@ class TestConfig(unittest.TestCase):
self.assertEqual(False, list1._api._dry_run)
self.assertEqual(3600, list2._kcr._interval)
self.assertEqual('/tmp/state.json', list1._kcr._state_file)
self.assertIn('Regards, {schleuder}', list2._kcr._template)
self.assertIn('Regards, {schleuder}', list2._kcr._key_template)
self.assertIn('Kind regards, {schleuder}', list2._kcr._user_template)
self.assertEqual('test-global@schleuder.example.org', list1._target)
self.assertEqual('test-global-owner@schleuder.example.org', list1._mail_from)

View file

@ -8,7 +8,8 @@ from unittest.mock import patch, mock_open, MagicMock
import pgpy # type: ignore
from dateutil.tz import tzutc
from multischleuder.conflict import ConflictMessage, KeyConflictResolution
from multischleuder.conflict import KeyConflictResolution
from multischleuder.reporting import UserConflictMessage
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
@ -52,7 +53,13 @@ QBzu2Swgk6MkU45SLQD+LagpBVJxHcbvmK+n8MFvTSrusF8H78P4TrMLP4Onvw4=
''')
_TEMPLATE = '''{{
_KEY_TEMPLATE = '''{{
"subscriber": "{subscriber}",
"schleuder": "{schleuder}",
"chosen": "{chosen}"
}}'''
_USER_TEMPLATE = '''{{
"fingerprint": "{fingerprint}",
"subscriber": "{subscriber}",
"schleuder": "{schleuder}",
"chosen": "{chosen}"
@ -70,7 +77,7 @@ _CONFLICT_STATE_RECENT = f'''{{
class TestKeyConflictResolution(unittest.TestCase):
def test_order_resistent_hash(self):
def test_order_resistent_key_hash(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
@ -79,19 +86,39 @@ class TestKeyConflictResolution(unittest.TestCase):
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
digest1 = kcr._make_digest(sub2, [sub1, sub2])
digest2 = kcr._make_digest(sub2, [sub2, sub1])
digest3 = kcr._make_digest(sub1, [sub1, sub2])
digest4 = kcr._make_digest(sub1, [sub2, sub1])
digest1 = kcr._make_key_digest(sub2, [sub1, sub2])
digest2 = kcr._make_key_digest(sub2, [sub2, sub1])
digest3 = kcr._make_key_digest(sub1, [sub1, sub2])
digest4 = kcr._make_key_digest(sub1, [sub2, sub1])
self.assertEqual(digest1, digest2)
self.assertNotEqual(digest1, digest3)
self.assertEqual(digest3, digest4)
def test_order_resistent_user_hash(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
digest1 = kcr._make_user_digest(sub2, [sub1, sub2])
digest2 = kcr._make_user_digest(sub2, [sub2, sub1])
digest3 = kcr._make_user_digest(sub1, [sub1, sub2])
digest4 = kcr._make_user_digest(sub1, [sub2, sub1])
self.assertEqual(digest1, digest2)
self.assertNotEqual(digest1, digest3)
self.assertEqual(digest3, digest4)
def test_empty(self):
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -105,7 +132,7 @@ class TestKeyConflictResolution(unittest.TestCase):
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
resolved, messages = kcr.resolve('', '', [sub1])
self.assertEqual(1, len(resolved))
self.assertEqual(sub1, resolved[0])
@ -123,7 +150,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -150,7 +177,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -187,7 +214,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', None, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -207,7 +234,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -219,6 +246,114 @@ class TestKeyConflictResolution(unittest.TestCase):
self.assertEqual(0, len(resolved))
self.assertEqual(0, len(messages))
def test_different_emails_conflict(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
# This subscription is older, so it will be preferred
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
mock_statefile().__enter__.return_value = contents
resolved, messages = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
subscriptions=[sub1, sub2])
self.assertEqual(1, len(resolved))
self.assertEqual('bar@example.org', resolved[0].email)
# Different emails should trigger conflict messages
self.assertEqual(2, len(messages))
self.assertIsInstance(messages[0], UserConflictMessage)
self.assertIsInstance(messages[1], UserConflictMessage)
msg1 = messages[0].mime
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
msg2 = messages[1].mime
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
# Verify that the message is encrypted
dec1 = _PRIVKEY_1.decrypt(pgp1)
dec2 = _PRIVKEY_1.decrypt(pgp2)
self.assertNotEqual(dec1.message, dec2.message)
payload1 = json.loads(dec1.message)
payload2 = json.loads(dec2.message)
if payload1['subscriber'] != 'foo@example.org':
payload1, payload2 = payload2, payload1
self.assertEqual('foo@example.org', payload1['subscriber'])
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
self.assertEqual('bar@example.org', payload1['chosen'])
self.assertEqual('bar@example.org', payload2['subscriber'])
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
self.assertEqual('bar@example.org', payload2['chosen'])
def test_zigzag_conflict(self):
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_2.pubkey), sch1.id)
sub1 = SchleuderSubscriber(1, 'foo@example.org', key1, sch1.id, date2)
sub2 = SchleuderSubscriber(2, 'bar@example.org', key2, sch1.id, date2)
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
key3 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
sub3 = SchleuderSubscriber(7, 'bar@example.org', key3, sch2.id, date1)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
mock_statefile().__enter__.return_value = contents
resolved, messages = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
subscriptions=[sub1, sub2, sub3])
self.assertEqual(2, len(resolved))
foo, bar = resolved
if foo.email != 'foo@example.org':
foo, bar = bar, foo
self.assertEqual('foo@example.org', foo.email)
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', foo.key.fingerprint)
self.assertEqual('bar@example.org', bar.email)
self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4', bar.key.fingerprint)
# Different emails should trigger conflict messages
self.assertEqual(2, len(messages))
self.assertIsInstance(messages[0], UserConflictMessage)
self.assertIsInstance(messages[1], UserConflictMessage)
msg1 = messages[0].mime
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
msg2 = messages[1].mime
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
# Verify that the message is encrypted
dec1 = _PRIVKEY_1.decrypt(pgp1)
dec2 = _PRIVKEY_1.decrypt(pgp2)
self.assertNotEqual(dec1.message, dec2.message)
payload1 = json.loads(dec1.message)
payload2 = json.loads(dec2.message)
if payload1['subscriber'] != 'foo@example.org':
payload1, payload2 = payload2, payload1
self.assertEqual('foo@example.org', payload1['subscriber'])
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
self.assertEqual('foo@example.org', payload1['chosen'])
self.assertEqual('bar@example.org', payload2['subscriber'])
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
self.assertEqual('foo@example.org', payload2['chosen'])
def test_send_messages_nofile(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
@ -230,7 +365,7 @@ class TestKeyConflictResolution(unittest.TestCase):
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
resolved, msgs = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
@ -248,7 +383,7 @@ class TestKeyConflictResolution(unittest.TestCase):
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO('[[intentionally/broken\\json]]')
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data='[[intentionally/broken\\json]]')) as mock_statefile:
@ -270,7 +405,7 @@ class TestKeyConflictResolution(unittest.TestCase):
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO()
with patch('builtins.open', mock_open(read_data='')) as mock_statefile:
mock_statefile().__enter__.return_value = contents
@ -299,7 +434,7 @@ class TestKeyConflictResolution(unittest.TestCase):
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
@ -318,12 +453,6 @@ class TestKeyConflictResolution(unittest.TestCase):
self.assertIn(msgs[0].mime['X-MultiSchleuder-Digest'], state)
self.assertLess(now - state[msgs[0].mime['X-MultiSchleuder-Digest']], 60)
# Todo: move this over to test_multilist
# mock_smtp = MagicMock()
# mock_smtp.__enter__.return_value = mock_smtp
# mock_smtp.__enter__.assert_called_once()
# self.assertEqual(2, mock_smtp.send_message.call_count)
def test_send_messages_stalestate(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
@ -335,7 +464,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_STALE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile:
@ -366,7 +495,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(86400, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(86400, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_RECENT)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_RECENT)) as mock_statefile:
@ -398,7 +527,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
kcr.dry_run()
contents = io.StringIO(_CONFLICT_STATE_STALE)
contents.seek(io.SEEK_END) # Opened with 'a+'
@ -431,7 +560,7 @@ class TestKeyConflictResolution(unittest.TestCase):
date3 = datetime(2022, 4, 11, 5, 23, 42, 0, tzinfo=tzutc())
sub3 = SchleuderSubscriber(7, 'foo@example.org', None, sch3.id, date3)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
contents = io.StringIO(_CONFLICT_STATE_NONE)
contents.seek(io.SEEK_END) # Opened with 'a+'
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:

View file

@ -8,13 +8,13 @@ from unittest.mock import MagicMock
from dateutil.tz import tzutc
from multischleuder.processor import MultiList
from multischleuder.reporting import ConflictMessage
from multischleuder.reporting import Message
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
def _resolve(target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[ConflictMessage]]:
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Message]]:
# Mock conflict resolution that does not send or prepare messages
subs: Dict[str, List[SchleuderSubscriber]] = {}
for s in subscriptions:

View file

@ -3,13 +3,16 @@ import unittest
from datetime import datetime
from multischleuder.reporting import ConflictMessage, AdminReport, Reporter
from multischleuder.reporting import KeyConflictMessage, AdminReport, Reporter, UserConflictMessage
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
from multischleuder.test.test_conflict import _PRIVKEY_1
def one_of_each_kind():
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
msg1 = ConflictMessage(
key = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), 1)
sub2 = SchleuderSubscriber(2, 'bar@example.org', key, 1, datetime.utcnow())
msg1 = KeyConflictMessage(
schleuder='test@example.org',
chosen=sub,
affected=[sub],
@ -26,7 +29,15 @@ def one_of_each_kind():
updated={},
added={},
removed={})
return [msg1, msg2]
msg3 = UserConflictMessage(
schleuder='test@example.org',
subscriber='bar@example.org',
chosen=sub2,
affected=[sub2],
digest='digest',
mail_from='test-owner@example.org',
template='averylongmessage')
return [msg1, msg2, msg3]
class TestReporting(unittest.TestCase):
@ -36,11 +47,13 @@ class TestReporting(unittest.TestCase):
r = Reporter(send_conflict_messages=True,
send_admin_reports=True)
r.add_messages(msgs)
self.assertEquals(2, len(Reporter.get_messages()))
self.assertIsInstance(Reporter.get_messages()[-2], ConflictMessage)
self.assertEquals('foo@example.org', Reporter.get_messages()[-2]._to)
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
self.assertEqual(3, len(Reporter.get_messages()))
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
self.assertIsInstance(Reporter.get_messages()[1], AdminReport)
self.assertEqual('admin@example.org', Reporter.get_messages()[1]._to)
self.assertIsInstance(Reporter.get_messages()[2], UserConflictMessage)
self.assertEqual('bar@example.org', Reporter.get_messages()[2]._to)
Reporter.clear_messages()
def test_reporter_config_conflict_only(self):
@ -48,9 +61,11 @@ class TestReporting(unittest.TestCase):
r = Reporter(send_conflict_messages=True,
send_admin_reports=False)
r.add_messages(msgs)
self.assertEquals(1, len(Reporter.get_messages()))
self.assertIsInstance(Reporter.get_messages()[-1], ConflictMessage)
self.assertEquals('foo@example.org', Reporter.get_messages()[-1]._to)
self.assertEqual(2, len(Reporter.get_messages()))
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
self.assertIsInstance(Reporter.get_messages()[1], UserConflictMessage)
self.assertEqual('bar@example.org', Reporter.get_messages()[1]._to)
Reporter.clear_messages()
def test_reporter_config_admin_only(self):
@ -58,9 +73,9 @@ class TestReporting(unittest.TestCase):
r = Reporter(send_conflict_messages=False,
send_admin_reports=True)
r.add_messages(msgs)
self.assertEquals(1, len(Reporter.get_messages()))
self.assertEqual(1, len(Reporter.get_messages()))
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
self.assertEqual('admin@example.org', Reporter.get_messages()[-1]._to)
Reporter.clear_messages()
def test_reporter_config_all_disabled(self):
@ -68,11 +83,11 @@ class TestReporting(unittest.TestCase):
r = Reporter(send_conflict_messages=False,
send_admin_reports=False)
r.add_messages(msgs)
self.assertEquals(0, len(Reporter.get_messages()))
self.assertEqual(0, len(Reporter.get_messages()))
def test_reporter_null_message(self):
r = Reporter(send_conflict_messages=True,
send_admin_reports=True)
r.add_messages([None])
self.assertEquals(0, len(Reporter.get_messages()))
self.assertEqual(0, len(Reporter.get_messages()))
Reporter.clear_messages()

View file

@ -7,7 +7,7 @@ from email.mime.text import MIMEText
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, SMTP
from multischleuder.reporting import ConflictMessage, AdminReport
from multischleuder.reporting import KeyConflictMessage, AdminReport
from multischleuder.smtp import SmtpClient, TlsMode
from multischleuder.types import SchleuderSubscriber
@ -170,7 +170,7 @@ class TestSmtpClient(unittest.TestCase):
username='example',
password='supersecurepassword')
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
msg1 = ConflictMessage(
msg1 = KeyConflictMessage(
schleuder='test@example.org',
chosen=sub,
affected=[sub],
@ -203,7 +203,7 @@ class TestSmtpClient(unittest.TestCase):
password='supersecurepassword')
client.dry_run()
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
msg1 = ConflictMessage(
msg1 = KeyConflictMessage(
schleuder='test@example.org',
chosen=sub,
affected=[sub],

View file

@ -40,7 +40,7 @@ smtp:
conflict:
interval: 3600 # 1 hour - you don't want this in production
statefile: /tmp/conflict.json
template: |
key_template: |
Hi {subscriber},
While compiling the subscriber list of {schleuder}, your
@ -65,8 +65,32 @@ conflict:
message, please refer to your local Schleuder admin, or reply to this
message.
Note that this automated message is unsigned, since MultiSchleuder does
not have access to Schleuder private keys.
Regards
MultiSchleuder {schleuder}
user_template: |
Hi {subscriber},
While compiling the subscriber list of {schleuder}, your
key {fingerprint} was used by subscribers on multiple sub-lists with
different email adresses. There may be something fishy or malicious
going on, or this may simply have been a mistake by you or a list admin.
You have only been subscribed to {schleuder} using the address you
have been subscribed with for the *longest* time:
{chosen}
Please review the following adresses and talk to the admins of the
corresponding sub-lists to resolve this issue:
Adress Sub-List
------ --------
{affected}
For your convenience, this message has been sent to *all* of the above
adresses. If you have any questions, or do not understand this
message, please refer to your local Schleuder admin, or reply to this
message.
Regards
MultiSchleuder {schleuder}