launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #01550
[Merge] lp:~mbp/launchpad/dkim into lp:launchpad
You have been requested to review the proposed merge of lp:~mbp/launchpad/dkim into lp:launchpad.
This fixes a few more things related to authentication of incoming mail:
* gpg signature timestamp checks should cover all mail, not just that to malone (bug 643200)
* checks on mail to new@ should make sure it's strongly authenticated, not specifically that it has a gpg signature (bug 643219)
* there was no test that gpg mail with implausible timestamps was actually rejected afaics
I haven't interactively tested this and I'm not utterly confident in the existing test coverage, so please review carefully and test it interactively yourself if you know how.
MaloneHandler.process was a bit large so I split out the code that decides what if any commands will be executed.
Thanks
--
https://code.launchpad.net/~mbp/launchpad/dkim/+merge/35985
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~mbp/launchpad/dkim into lp:launchpad.
=== renamed file 'lib/canonical/launchpad/mail/errortemplates/not-gpg-signed.txt' => 'lib/canonical/launchpad/mail/errortemplates/unauthenticated-bug-creation.txt'
=== modified file 'lib/canonical/launchpad/mail/handlers.py'
--- lib/canonical/launchpad/mail/handlers.py 2010-10-03 15:30:06 +0000
+++ lib/canonical/launchpad/mail/handlers.py 2010-10-15 09:21:30 +0000
@@ -32,14 +32,12 @@
ISpecificationSet,
QuestionStatus,
)
-from canonical.launchpad.interfaces.gpghandler import IGPGHandler
from canonical.launchpad.mail.commands import (
BugEmailCommands,
get_error_message,
)
from canonical.launchpad.mail.helpers import (
ensure_not_weakly_authenticated,
- ensure_sane_signature_timestamp,
get_main_body,
guess_bugtask,
IncomingEmailError,
@@ -59,16 +57,6 @@
)
-def extract_signature_timestamp(signed_msg):
- # break import cycle
- from canonical.launchpad.mail.incoming import (
- canonicalise_line_endings)
- signature = getUtility(IGPGHandler).getVerifiedSignature(
- canonicalise_line_endings(signed_msg.signedContent),
- signed_msg.signature)
- return signature.timestamp
-
-
class MaloneHandler:
"""Handles emails sent to Malone.
@@ -88,47 +76,65 @@
name, args in parse_commands(content,
BugEmailCommands.names())]
- def process(self, signed_msg, to_addr, filealias=None, log=None,
- extract_signature_timestamp=extract_signature_timestamp):
- """See IMailHandler."""
+ def extractAndAuthenticateCommands(self, signed_msg, to_addr):
+ """Extract commands and handle special destinations.
+
+ NB: The authentication is carried out against the current principal,
+ not directly against the message. authenticateEmail must previously
+ have been called on this thread.
+
+ :returns: (final_result, add_comment_to_bug, commands)
+ If final_result is non-none, stop processing and return this value
+ to indicate whether the message was dealt with or not.
+ If add_comment_to_bug, add the contents to the first bug
+ selected.
+ commands is a list of bug commands.
+ """
+ CONTEXT = 'bug report'
commands = self.getCommands(signed_msg)
- user, host = to_addr.split('@')
+ to_user, to_host = to_addr.split('@')
add_comment_to_bug = False
- signature = signed_msg.signature
+ if len(commands) > 0:
+ # If there are any commands, we must have strong authentication.
+ # We send a different failure message for attempts to create a new
+ # bug.
+ if to_user.lower() == 'new':
+ ensure_not_weakly_authenticated(signed_msg, CONTEXT,
+ 'unauthenticated-bug-creation.txt')
+ else:
+ ensure_not_weakly_authenticated(signed_msg, CONTEXT)
+ if to_user.lower() == 'new':
+ commands.insert(0, BugEmailCommands.get('bug', ['new']))
+ elif to_user.isdigit():
+ # A comment to a bug. We set add_comment_to_bug to True so
+ # that the comment gets added to the bug later. We don't add
+ # the comment now, since we want to let the 'bug' command
+ # handle the possible errors that can occur while getting
+ # the bug.
+ add_comment_to_bug = True
+ commands.insert(0, BugEmailCommands.get('bug', [to_user]))
+ elif to_user.lower() == 'help':
+ from_user = getUtility(ILaunchBag).user
+ if from_user is not None:
+ preferredemail = from_user.preferredemail
+ if preferredemail is not None:
+ to_address = str(preferredemail.email)
+ self.sendHelpEmail(to_address)
+ return True, False, None
+ elif to_user.lower() != 'edit':
+ # Indicate that we didn't handle the mail.
+ return False, False, None
+ return None, add_comment_to_bug, commands
+
+ def process(self, signed_msg, to_addr, filealias=None, log=None):
+ """See IMailHandler."""
try:
- if len(commands) > 0:
- CONTEXT = 'bug report'
- ensure_not_weakly_authenticated(signed_msg, CONTEXT)
- if signature is not None:
- ensure_sane_signature_timestamp(
- extract_signature_timestamp(signed_msg), CONTEXT)
-
- if user.lower() == 'new':
- # A submit request.
- commands.insert(0, BugEmailCommands.get('bug', ['new']))
- if signature is None:
- raise IncomingEmailError(
- get_error_message('not-gpg-signed.txt'))
- elif user.isdigit():
- # A comment to a bug. We set add_comment_to_bug to True so
- # that the comment gets added to the bug later. We don't add
- # the comment now, since we want to let the 'bug' command
- # handle the possible errors that can occur while getting
- # the bug.
- add_comment_to_bug = True
- commands.insert(0, BugEmailCommands.get('bug', [user]))
- elif user.lower() == 'help':
- from_user = getUtility(ILaunchBag).user
- if from_user is not None:
- preferredemail = from_user.preferredemail
- if preferredemail is not None:
- to_address = str(preferredemail.email)
- self.sendHelpEmail(to_address)
- return True
- elif user.lower() != 'edit':
- # Indicate that we didn't handle the mail.
- return False
+ (final_result, add_comment_to_bug,
+ commands, ) = self.extractAndAuthenticateCommands(
+ signed_msg, to_addr)
+ if final_result is not None:
+ return final_result
bug = None
bug_event = None
=== modified file 'lib/canonical/launchpad/mail/helpers.py'
--- lib/canonical/launchpad/mail/helpers.py 2010-10-03 15:30:06 +0000
+++ lib/canonical/launchpad/mail/helpers.py 2010-10-15 09:21:30 +0000
@@ -211,7 +211,14 @@
def ensure_not_weakly_authenticated(signed_msg, context,
error_template='not-signed.txt',
no_key_template='key-not-registered.txt'):
- """Make sure that the current principal is not weakly authenticated."""
+ """Make sure that the current principal is not weakly authenticated.
+
+ NB: While handling an email, the authentication state is stored partly in
+ properties of the message object, and partly in the current security
+ principal. As a consequence this function will only work correctly if the
+ message has just been passed through authenticateEmail -- you can't give
+ it an arbitrary message object.
+ """
cur_principal = get_current_principal()
# The security machinery doesn't know about
# IWeaklyAuthenticatedPrincipal yet, so do a manual
@@ -232,7 +239,10 @@
def ensure_sane_signature_timestamp(timestamp, context,
error_template='old-signature.txt'):
- """Ensure the signature was generated recently but not in the future."""
+ """Ensure the signature was generated recently but not in the future.
+
+ :raises IncomingEmailError: if the timestamp is stale or implausible.
+ """
fourty_eight_hours = 48 * 60 * 60
ten_minutes = 10 * 60
now = time.time()
=== modified file 'lib/canonical/launchpad/mail/incoming.py'
--- lib/canonical/launchpad/mail/incoming.py 2010-10-03 15:30:06 +0000
+++ lib/canonical/launchpad/mail/incoming.py 2010-10-15 09:21:30 +0000
@@ -38,6 +38,9 @@
)
from canonical.launchpad.mail.commands import get_error_message
from canonical.launchpad.mail.handlers import mail_handlers
+from canonical.launchpad.mail.helpers import (
+ ensure_sane_signature_timestamp,
+ )
from canonical.launchpad.mailnotification import (
send_process_error_notification,
)
@@ -149,7 +152,7 @@
% (signing_domain, from_domain))
return False
if not _isDkimDomainTrusted(signing_domain):
- log.warning("valid DKIM signature from untrusted domain %s"
+ log.warning("valid DKIM signature from untrusted domain %s"
% (signing_domain,))
return False
return True
@@ -159,10 +162,12 @@
"""Authenticates an email by verifying the PGP signature.
The mail is expected to be an ISignedMessage.
+
+ If this completes, it will set the current security principal to be the
+ message sender.
"""
signature = mail.signature
- signed_content = mail.signedContent
name, email_addr = parseaddr(mail['From'])
authutil = getUtility(IPlacelessAuthUtility)
@@ -202,11 +207,15 @@
gpghandler = getUtility(IGPGHandler)
try:
sig = gpghandler.getVerifiedSignature(
- canonicalise_line_endings(signed_content), signature)
+ canonicalise_line_endings(mail.signedContent), signature)
except GPGVerificationError, e:
# verifySignature failed to verify the signature.
raise InvalidSignature("Signature couldn't be verified: %s" % str(e))
+ ensure_sane_signature_timestamp(
+ sig.timestamp,
+ 'incoming mail verification')
+
for gpgkey in person.gpg_keys:
if gpgkey.fingerprint == sig.fingerprint:
break
=== modified file 'lib/canonical/launchpad/mail/tests/test_handlers.py'
--- lib/canonical/launchpad/mail/tests/test_handlers.py 2010-10-04 19:50:45 +0000
+++ lib/canonical/launchpad/mail/tests/test_handlers.py 2010-10-15 09:21:30 +0000
@@ -8,6 +8,7 @@
from doctest import DocTestSuite
import email
import time
+import transaction
import unittest
from canonical.database.sqlbase import commit
@@ -44,6 +45,66 @@
self.assertEqual('bug', commands[0].name)
self.assertEqual(['foo'], commands[0].string_args)
+ def test_NonGPGAuthenticatedNewBug(self):
+ """Mail authenticated other than by gpg can create bugs.
+
+ The incoming mail layer is responsible for authenticating the mail,
+ and setting the current principal to the sender of the mail, either
+ weakly or non-weakly authenticated. At the layer of the handler,
+ which this class is testing, we shouldn't care by what mechanism we
+ decided to act on behalf of the mail sender, only that we did.
+
+ In bug 643219, Launchpad had a problem where the MaloneHandler code
+ was puncturing that abstraction and directly looking at the GPG
+ signature; this test checks it's fixed.
+ """
+ # NB SignedMessage by default isn't actually signed, it just has the
+ # capability of knowing about signing.
+ message = self.factory.makeSignedMessage(body=' affects malone\nhi!')
+ self.assertEquals(message.signature, None)
+
+ # Pretend that the mail auth has given us a logged-in user.
+ handler = MaloneHandler()
+ with person_logged_in(self.factory.makePerson()):
+ mail_handled, add_comment_to_bug, commands = \
+ handler.extractAndAuthenticateCommands(message,
+ 'new@xxxxxxxxxxxxxxxxxx')
+ self.assertEquals(mail_handled, None)
+ self.assertEquals(map(str, commands), [
+ 'bug new',
+ 'affects malone',
+ ])
+
+ def test_mailToHelpFromUnknownUser(self):
+ """Mail from people of no account to help@ is simply dropped.
+ """
+ message = self.factory.makeSignedMessage()
+ handler = MaloneHandler()
+ mail_handled, add_comment_to_bug, commands = \
+ handler.extractAndAuthenticateCommands(message,
+ 'help@xxxxxxxxxxxxxxxxxx')
+ self.assertEquals(mail_handled, True)
+ self.assertEquals(self.getSentMail(), [])
+
+ def test_mailToHelp(self):
+ """Mail to help@ generates a help command."""
+ message = self.factory.makeSignedMessage()
+ handler = MaloneHandler()
+ with person_logged_in(self.factory.makePerson()):
+ mail_handled, add_comment_to_bug, commands = \
+ handler.extractAndAuthenticateCommands(message,
+ 'help@xxxxxxxxxxxxxxxxxx')
+ self.assertEquals(mail_handled, True)
+ self.assertEquals(len(self.getSentMail()), 1)
+ # TODO: Check the right mail was sent. -- mbp 20100923
+
+ def getSentMail(self):
+ # Sending mail is (unfortunately) a side effect of parsing the
+ # commands, and unfortunately you must commit the transaction to get
+ # them sent.
+ transaction.commit()
+ return stub.test_emails[:]
+
class FakeSignature:
=== modified file 'lib/canonical/launchpad/mail/tests/test_incoming.py'
--- lib/canonical/launchpad/mail/tests/test_incoming.py 2010-10-04 19:50:45 +0000
+++ lib/canonical/launchpad/mail/tests/test_incoming.py 2010-10-15 09:21:30 +0000
@@ -7,15 +7,29 @@
import transaction
+from canonical.launchpad.ftests import import_secret_test_key
+from canonical.launchpad.interfaces import (
+ IWeaklyAuthenticatedPrincipal,
+ )
from canonical.launchpad.mail.ftests.helpers import testmails_path
+from canonical.launchpad.mail import (
+ helpers,
+ )
from canonical.launchpad.mail.incoming import (
+ authenticateEmail,
handleMail,
MailErrorUtility,
)
+<<<<<<< TREE
from canonical.testing.layers import LaunchpadZopelessLayer
+=======
+from canonical.launchpad.webapp.interaction import get_current_principal
+from canonical.testing import LaunchpadZopelessLayer
+>>>>>>> MERGE-SOURCE
from lp.services.mail.sendmail import MailController
from lp.services.mail.stub import TestMailer
from lp.testing import TestCaseWithFactory
+from lp.testing.factory import GPGSigningContext
from lp.testing.mail_helpers import pop_notifications
@@ -76,6 +90,32 @@
else:
self.assertEqual(old_oops.id, current_oops.id)
+ def test_bad_signature_timestamp(self):
+ """If the signature is nontrivial future-dated, it's not trusted."""
+
+ signing_context = GPGSigningContext(
+ import_secret_test_key().fingerprint, password='test')
+ msg = self.factory.makeSignedMessage(signing_context=signing_context)
+ # it's not easy (for me) to make a gpg signature with a bogus
+ # timestamp, so instead we'll just make sure that it is in fact
+ # checked
+ self._hook_timestamp_check()
+ authenticateEmail(msg)
+ self.assertTrue(
+ IWeaklyAuthenticatedPrincipal.providedBy(get_current_principal()))
+
+ def _hook_timestamp_check(self):
+ saved = helpers.ensure_sane_signature_timestamp
+
+ def restore():
+ helpers.ensure_sane_signature_timestamp = saved
+
+ def fail(timestamp, context):
+ raise helpers.IncomingEmailError("fail!")
+
+ self.addCleanup(restore)
+ helpers.ensure_sane_signature_timestamp = fail
+
def test_suite():
suite = unittest.TestSuite()
=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
--- lib/lp/bugs/tests/bugs-emailinterface.txt 2010-10-04 19:50:45 +0000
+++ lib/lp/bugs/tests/bugs-emailinterface.txt 2010-10-15 09:21:30 +0000
@@ -50,6 +50,8 @@
signed, so that the system can verify the sender. But to avoid having
to sign each email, we'll create a class which fakes a signed email:
+ >>> from lp.testing import sampledata
+
>>> import email.Message
>>> class MockSignedMessage(email.Message.Message):
... def __init__(self, *args, **kws):
@@ -77,14 +79,10 @@
... msg['Message-Id'] = factory.makeUniqueRFC822MsgId()
... return msg
- >>> import time
- >>> def fake_extract_signature_timestamp(signed_msg):
- ... return time.time()
-
>>> def process_email(raw_mail):
... msg = construct_email(raw_mail)
... handler.process(msg, msg['To'],
- ... extract_signature_timestamp=fake_extract_signature_timestamp)
+ ... )
>>> process_email(submit_mail)
@@ -156,7 +154,7 @@
If we would file a bug on Ubuntu instead, we would submit a mail like
this:
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> submit_mail = """From: Sample Person <test@xxxxxxxxxxxxx>
... To: new@xxxxxxxxxxxxxxxxxx
... Date: Fri Jun 17 10:20:23 BST 2005
@@ -343,13 +341,15 @@
... IWeaklyAuthenticatedPrincipal)
>>> from zope.interface import directlyProvides, directlyProvidedBy
>>> from zope.security.management import queryInteraction
- >>> participations = queryInteraction().participations
- >>> len(participations)
- 1
- >>> current_principal = participations[0].principal
- >>> directlyProvides(
- ... current_principal, directlyProvidedBy(current_principal),
- ... IWeaklyAuthenticatedPrincipal)
+
+ >>> def simulate_receiving_untrusted_mail():
+ ... participations = queryInteraction().participations
+ ... assert len(participations) == 1
+ ... current_principal = participations[0].principal
+ ... directlyProvides(
+ ... current_principal, directlyProvidedBy(current_principal),
+ ... IWeaklyAuthenticatedPrincipal)
+ >>> simulate_receiving_untrusted_mail()
Now we send a comment containing commands.
@@ -386,6 +386,8 @@
>>> def print_latest_email():
... commit()
+ ... if not stub.test_emails:
+ ... raise AssertionError("No emails queued!")
... from_addr, to_addrs, raw_message = stub.test_emails[-1]
... sent_msg = email.message_from_string(raw_message)
... error_mail, original_mail = sent_msg.get_payload()
@@ -412,7 +414,7 @@
... comment_mail, _class=MockUnsignedMessage)
>>> handler.process(
... msg, msg['To'],
- ... extract_signature_timestamp=fake_extract_signature_timestamp)
+ ... )
True
>>> commit()
@@ -458,12 +460,9 @@
>>> added_message in bug_one.messages
True
-Unmark the principal:
+In these tests, every time we log in, we're fully trusted again:
- >>> provided_interfaces = directlyProvidedBy(current_principal)
- >>> directlyProvides(
- ... current_principal,
- ... provided_interfaces - IWeaklyAuthenticatedPrincipal)
+ >>> login(sampledata.USER_EMAIL)
Commands
@@ -486,7 +485,7 @@
>>> def submit_command_email(msg):
... handler.process(
... msg, msg['To'],
- ... extract_signature_timestamp=fake_extract_signature_timestamp)
+ ... )
... commit()
... sync(bug)
@@ -735,7 +734,7 @@
>>> 'Foo Bar' in [subscription.person.displayname
... for subscription in bug_four.subscriptions]
False
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> submit_commands(bug_four, 'unsubscribe')
>>> 'Sample Person' in [subscription.person.displayname
... for subscription in bug_four.subscriptions]
@@ -794,9 +793,9 @@
... for subscriber in bug_five.getIndirectSubscribers()])
[u'Sample Person', u'Ubuntu Team']
-(Log back in as test@xxxxxxxxxxxxx for the tests that follow.)
+(Log back in for the tests that follow.)
- >>> login("test@xxxxxxxxxxxxx")
+ >>> login(sampledata.USER_EMAIL)
If we specify a non-existant user, an error message will be sent:
@@ -1109,7 +1108,7 @@
Attempting to set the milestone for a bug without sufficient
permissions also elicits an error message:
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> bug = new_firefox_bug()
>>> commit()
@@ -1135,7 +1134,7 @@
Only owners, drivers and bug supervisors may assign milestones.
...
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
Like the web UI, we can assign a bug to nobody.
@@ -1231,10 +1230,10 @@
>>> login('foo.bar@xxxxxxxxxxxxx')
>>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
>>> ubuntu.driver = getUtility(IPersonSet).getByEmail(
- ... 'test@xxxxxxxxxxxxx')
+ ... sampledata.USER_EMAIL)
>>> commit()
>>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> sync(bug)
Now a new bugtask for the series will be create directly.
@@ -1287,7 +1286,7 @@
>>> ubuntu.driver = None
>>> commit()
>>> LaunchpadZopelessLayer.switchDbUser(config.processmail.dbuser)
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> bug = new_firefox_bug()
>>> for bugtask in bug.bugtasks:
@@ -1314,7 +1313,7 @@
... print driver.displayname
Sample Person
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> submit_commands(bug, 'affects /firefox/trunk')
>>> for bugtask in bug.bugtasks:
@@ -1345,7 +1344,7 @@
... print nomination.target.bugtargetdisplayname
Evolution trunk
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
Let's take on the upstream task on bug four as well. This time we'll
sneak in a 'subscribe' command between the 'affects' and the other
@@ -1620,7 +1619,7 @@
The user is a bug supervisors of the upstream product
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> bug_one = getUtility(IBugSet).get(1)
>>> submit_commands(
... bug_one, 'status confirmed', 'assignee test@xxxxxxxxxxxxx')
@@ -1724,6 +1723,7 @@
If none of the bug tasks can be chosen, an error message is sent to the
user, telling him that he has to use the 'affects' command.
+ >>> del stub.test_emails[:]
>>> login('stuart.bishop@xxxxxxxxxxxxx')
>>> submit_commands(
... bug_one, 'status new', 'assignee foo.bar@xxxxxxxxxxxxx')
@@ -1754,7 +1754,9 @@
him about the error. Let's start with trying to submit a bug without
signing the mail:
- >>> login('test@xxxxxxxxxxxxx')
+ >>> del stub.test_emails[:]
+ >>> login(sampledata.USER_EMAIL)
+ >>> simulate_receiving_untrusted_mail()
>>> from canonical.launchpad.mail import signed_message_from_string
>>> msg = signed_message_from_string(submit_mail)
@@ -1762,7 +1764,7 @@
>>> msg['Message-Id'] = email.Utils.make_msgid()
>>> handler.process(
... msg, msg['To'],
- ... extract_signature_timestamp=fake_extract_signature_timestamp)
+ ... )
True
>>> print_latest_email()
Subject: Submit Request Failure
@@ -1775,6 +1777,7 @@
A submit without specifying on what we want to file the bug on:
+ >>> login(sampledata.USER_EMAIL)
>>> submit_mail_no_bugtask = """From: test@xxxxxxxxxxxxx
... To: new@malone
... Date: Fri Jun 17 10:20:23 BST 2005
@@ -1981,7 +1984,7 @@
>>> from canonical.launchpad.mailnotification import (
... send_process_error_notification)
>>> send_process_error_notification(
- ... 'test@xxxxxxxxxxxxx', 'Some subject', 'Some error message.',
+ ... sampledata.USER_EMAIL, 'Some subject', 'Some error message.',
... msg, failing_command=['foo bar'])
The To and Subject headers got set to the values we provided:
@@ -2045,7 +2048,7 @@
First, we create a new firefox bug.
- >>> login('test@xxxxxxxxxxxxx')
+ >>> login(sampledata.USER_EMAIL)
>>> submit_mail = """From: Sample Person <test@xxxxxxxxxxxxx>
... To: new@xxxxxxxxxxxxxxxxxx
... Date: Fri Jun 17 10:20:23 BST 2006
=== modified file 'utilities/migrater/file-ownership.txt'
--- utilities/migrater/file-ownership.txt 2010-09-21 18:56:32 +0000
+++ utilities/migrater/file-ownership.txt 2010-10-15 09:21:30 +0000
@@ -1053,7 +1053,7 @@
./mail/errortemplates/num-arguments-mismatch.txt
./mail/errortemplates/no-such-bug.txt
./mail/errortemplates/security-parameter-mismatch.txt
- ./mail/errortemplates/not-gpg-signed.txt
+ ./mail/errortemplates/unauthenticated-bug-creation.txt
./mail/errortemplates/key-not-registered.txt
./mail/errortemplates/oops.txt
./mail/errortemplates/branchmergeproposal-exists.txt
References