diff --git a/multischleuder/conflict.py b/multischleuder/conflict.py index 604a3f5..111b793 100644 --- a/multischleuder/conflict.py +++ b/multischleuder/conflict.py @@ -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() diff --git a/multischleuder/reporting.py b/multischleuder/reporting.py index fbbcbc5..6019bcc 100644 --- a/multischleuder/reporting.py +++ b/multischleuder/reporting.py @@ -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 diff --git a/multischleuder/test/test_config.py b/multischleuder/test/test_config.py index 87e1475..1fe7f54 100644 --- a/multischleuder/test/test_config.py +++ b/multischleuder/test/test_config.py @@ -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) diff --git a/multischleuder/test/test_conflict.py b/multischleuder/test/test_conflict.py index 69ce4de..7da79e2 100644 --- a/multischleuder/test/test_conflict.py +++ b/multischleuder/test/test_conflict.py @@ -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: diff --git a/multischleuder/test/test_multilist.py b/multischleuder/test/test_multilist.py index 5b728d0..87587bb 100644 --- a/multischleuder/test/test_multilist.py +++ b/multischleuder/test/test_multilist.py @@ -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: diff --git a/multischleuder/test/test_reporting.py b/multischleuder/test/test_reporting.py index b8e69b2..c3f4bbe 100644 --- a/multischleuder/test/test_reporting.py +++ b/multischleuder/test/test_reporting.py @@ -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() diff --git a/multischleuder/test/test_smtp.py b/multischleuder/test/test_smtp.py index b6a12e0..a858d11 100644 --- a/multischleuder/test/test_smtp.py +++ b/multischleuder/test/test_smtp.py @@ -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], diff --git a/test/multischleuder.yml b/test/multischleuder.yml index 446df79..f48471b 100644 --- a/test/multischleuder.yml +++ b/test/multischleuder.yml @@ -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}