Merge lp:~jr/bzr/bzr-gpgme into lp:bzr

Proposed by Jonathan Riddell
Status: Merged
Approved by: Jonathan Riddell
Approved revision: no longer in the source branch.
Merge reported by: Jonathan Riddell
Merged at revision: not available
Proposed branch: lp:~jr/bzr/bzr-gpgme
Merge into: lp:bzr
Diff against target: 122 lines (+25/-16)
1 file modified
bzrlib/gpg.py (+25/-16)
To merge this branch: bzr merge lp:~jr/bzr/bzr-gpgme
Reviewer Review Type Date Requested Status
bzr-core Pending
Review via email: mp+64859@code.launchpad.net

Commit message

A new command ``bzr verify-signatures`` has been added to check that commits
are correctly signed with trusted keys by GPG. This requires python-gpgme to
be installed. ``bzr log`` has gained a ``--signatures`` option to list the
validity of signatures for each commit. New config options ``acceptable_keys``
and ``validate_signatures_in_log`` can be set to control options to these
commands.

Description of the change

Implement bzr verify command

verifies that commits on a branch are signed by trusted keys

uses gpgme, a new (non-required) dependency for bzr

sign-my-commits.py should probably be renamed

still some tidying up to do but I'd like feedback on it so far

To post a comment you must log in.
Revision history for this message
Jelmer Vernooij (jelmer) wrote :

Thanks for taking this on.. it's one of the oldest unfinished features in bzr I think :) Not a full review, but some quick notes:

VerifyFailed seems a bit too generic a name for something that applies only to verifying signatures. Perhaps something like SignatureVerificationFailed ?

GpgmeNotInstalled should probably derive from DependencyNotPresent (see also ParamikoNotPresent in bzrlib.errors).

Tests that use this should probably call self.requireFeature(GPGMeFeature) or something along those lines to make sure the tests are skipped for people that do not have gpgme installed.

Revision history for this message
Jonathan Riddell (jr) wrote :

Jelmer's suggestions added

Revision history for this message
Jelmer Vernooij (jelmer) wrote :

I think you're on the right track with this; here are some more comments. It would be great if somebody else (with more GPG experience) could also comment.

113 + if from_revno is None or to_revno is None:
114 + raise errors.BzrCommandError('Cannot verify a range of \
115 + non-revision-history revisions')
As far as I know this isn't possible - perhaps this should be an AssertionError rather than a BzrCommandError?

For consistency we've been adding --directory/-d option to most commands; it would be nice to support that to "bzr verify" as well.

I think the command name is perhaps a bit generic, perhaps it should be "bzr verify-signatures", with "verify" as one of its current aliases. We currently already have "bzr check" and to me, as a non-native speaker, these two words mean roughly the same thing in this context.

I'm unsure about supporting a --acceptable-keys argument to verify. I think it would be sufficient for verify to just check that the signatures match what is actually in the repository, leaving the issue of who to trust up to the user for the time being. I don't see myself using --acceptable-keys because I usually only care about the signature on a particular revision (not the history leading up to that point) or there are too many revisions and thus too many keys to specify (bzr has > 200 contributors, it's not practical to specify all their key ids on the command line).

We should watch out with displaying the signature status for each revision in "bzr log"; it has the potential to significantly slow down log if more people start signing their revisions.

Revision history for this message
Jonathan Riddell (jr) wrote :

> 113 + if from_revno is None or to_revno is None:
> 114 + raise errors.BzrCommandError('Cannot verify a range of \
> 115 + non-revision-history revisions')
> As far as I know this isn't possible - perhaps this should be an AssertionError rather than a BzrCommandError?

This happens if you specify versions from a merged revision, bzr verify -r 3.1.1..3.1.2 which isn't supported by this code
The code comes from builtins.cmd_re_sign()

> For consistency we've been adding --directory/-d option to most commands; it would be nice to support that to "bzr verify" as well.

Added.

> I think the command name is perhaps a bit generic, perhaps it should be "bzr verify-signatures", with "verify" as one of its current aliases.

Done.

> I'm unsure about supporting a --acceptable-keys argument to verify.

I trust lots of people to have digital keys that match their ID, but
it doesn't mean I trust them to commit to my code branch, so there
does need to be a way to specify which keys are acceptable.

> We should watch out with displaying the signature status for each
  revision in "bzr log"; it has the potential to significantly slow
  down log if more people start signing their revisions.

That's why it's an option which isn't on by default.

Jonathan

Revision history for this message
John A Meinel (jameinel) wrote :
Download full text (4.0 KiB)

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 6/21/2011 2:06 PM, Jonathan Riddell wrote:
>> 113 + if from_revno is None or to_revno is None:
>> 114 + raise errors.BzrCommandError('Cannot verify a range of \
>> 115 + non-revision-history revisions')
>> As far as I know this isn't possible - perhaps this should be an AssertionError rather than a BzrCommandError?
>
> This happens if you specify versions from a merged revision, bzr verify -r 3.1.1..3.1.2 which isn't supported by this code
> The code comes from builtins.cmd_re_sign()
>

Some thoughts:

[1 needs fixing] You are adding a call to "get_ancestry()" which Jelmer
just recently marked as deprecated. I imagine this is pretty easy to fix
following his examples.

[2 comment] You import 'gpgme' inside of builtins.py. I would have
expected that sort of thing to be hidden inside 'gpg.py'. Something like
"gpg.support_verifying_signatures()" or "check_can_verify_signatures", etc.

[3 tweak] You have too much code in "cmd_verify_signatures". Most of
that should probably be moved into gpg.py and the cmd_* code should be a
reasonably small shim. That usually makes it easier to re-use and test.
Especially if a GUI ever wants to borrow some of the verify code.

[4 needsinfo] GpgStrategy.verify(self, content, testament)

- From what I can tell, you create a new gpgme Context for every revision.
Which I *think* means spawning a new gpg process. It seems like a
scalable design would want to be able to hold the context for an
extended period of time. Could it be a lazy attribute of GPGStrategy?

[5 needsinfo] Would we want a way to distinguish from "signature is
properly signed by an un-acceptable user" from "signature not properly
signed"? The latter hints more of corruption. And certainly from a UI
level, it might prompt me to say "oh, I need to add this person" rather
than "why isn't there signature valid?".

Something like SIGNATURE_UNTRUSTED, or
SIGNATURE_VALID_BUT_NOT_AUTHORIZED, etc.

[6 needsinfo] You mention that it slows down "bzr log" quite a bit. Have
you tried --lsprof to see what that cause is? (Are we re-computing the
testament from scratch for each rev?) I think it would be reasonable to
consider being able to separate out "check the signatures are gpg-valid"
from "the revision matches its testament". Just because at present, the
latter is *very* expensive, but the former should be pretty cheap.

I believe that we want to make the latter cheaper so that it can scale
to more active use. However, I'm curious what the specific performance
overhead is.

[7 needsfixing] bzrlib/tests/blackbox/test_sign_my_commits.py
You're adding one blank line per statement, which isn't preferred form.
You also have a continuation line which is a bit unnatural. Something
more like:

def test_verify_commits(self):
    wt = self.setup_tree()
    self.monkey_patch_gpg()
    self.run_bzr('sign-my-commits')
    out = self.run_bzr('verify', retcode=1)
    self.assertEquals(('',
 '4 commits with valid signatures\n'
 '0 commits with unknown keys\n'
 '0 commits not valid'
 '1 commit not signed\n'), out)

I'm also wondering why we are...

Read more...

Revision history for this message
Jonathan Riddell (jr) wrote :

On Wed, Jun 22, 2011 at 11:43:37AM -0000, John A Meinel wrote:
> [1 needs fixing] You are adding a call to "get_ancestry()" which Jelmer
> just recently marked as deprecated. I imagine this is pretty easy to fix
> following his examples.

This was a bad merge, now fixed.

> [2 comment] You import 'gpgme' inside of builtins.py. I would have
> expected that sort of thing to be hidden inside 'gpg.py'. Something like
> "gpg.support_verifying_signatures()" or "check_can_verify_signatures", etc.

Done.

> [3 tweak] You have too much code in "cmd_verify_signatures". Most of
> that should probably be moved into gpg.py and the cmd_* code should be a
> reasonably small shim. That usually makes it easier to re-use and test.
> Especially if a GUI ever wants to borrow some of the verify code.

Done.

> [4 needsinfo] GpgStrategy.verify(self, content, testament)
>
> - From what I can tell, you create a new gpgme Context for every revision.
> Which I *think* means spawning a new gpg process. It seems like a
> scalable design would want to be able to hold the context for an
> extended period of time. Could it be a lazy attribute of GPGStrategy?

I've made the context global to the GpgStrategy class (although I
don't think it starts a new gpg process for each one).

> [5 needsinfo] Would we want a way to distinguish from "signature is
> properly signed by an un-acceptable user" from "signature not properly
> signed"?

There's no such concept as an unacceptable user, only acceptable ones.
Anything else is an unknown key and listed as such.

> [6 needsinfo] You mention that it slows down "bzr log" quite a bit. Have
> you tried --lsprof to see what that cause is?

The main cause is calls to verify() and get_key() in gpgme according to --lsprof.

> [7 needsfixing] bzrlib/tests/blackbox/test_sign_my_commits.py
> You're adding one blank line per statement, which isn't preferred form.

Done

> [8 needsfixing] I don't think we need the short-form of "verify". I
> agree with Jelmer that it sounds more like repository checking, and I
> don't think it is something people will run enough to need a short form for.
>
> The docs also need to be updated for this fact.

Done

> [9 needsfixing] We'll also want an entry in
> doc/en/release-notes/bzr-2.4.txt and probably
> doc/whatsnew/whats-new-in-2.4.txt.

Done

Jonathan

Revision history for this message
Jonathan Riddell (jr) wrote :

From discussion with JAM, needs to handle expired keys more gracefully, should give different error for unknown and unacceptable key, gpg verify should be commented so it can be easier understood and checked over by someone knowledgeable in gpgme.

Revision history for this message
John A Meinel (jameinel) wrote :

So I should clarify that while I agree with what needs to be done, we can
probably land this and do those bits as follow up cleanup. We don't want a
lot of drift, but I think this is incrementally better in all aspects, and
we shouldn't require it to be perfect. I'm sorry if I've been a bit
blockish, I have to remind myself sometimes. The rules for landing have
never been that it has to be perfect.

Merge: approve
John
=:->
On Jun 29, 2011 3:08 PM, "Jonathan Riddell" <email address hidden> wrote:
>>From discussion with JAM, needs to handle expired keys more gracefully,
should give different error for unknown and unacceptable key, gpg verify
should be commented so it can be easier understood and checked over by
someone knowledgeable in gpgme.
>
> --
> https://code.launchpad.net/~jr/bzr/bzr-gpgme/+merge/64859
> Your team bzr-core is requested to review the proposed merge of
lp:~jr/bzr/bzr-gpgme into lp:bzr.

Revision history for this message
Jonathan Riddell (jr) wrote :

sent to pqm by email

Revision history for this message
Jonathan Riddell (jr) wrote :

sent to pqm by email

Revision history for this message
Jonathan Riddell (jr) wrote :

sent to pqm by email

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'bzrlib/gpg.py'
--- bzrlib/gpg.py 2011-06-30 14:36:05 +0000
+++ bzrlib/gpg.py 2011-06-30 16:17:30 +0000
@@ -161,6 +161,9 @@
161161
162 @staticmethod162 @staticmethod
163 def verify_signatures_available():163 def verify_signatures_available():
164 """
165 :return: boolean if this strategy can verify signatures
166 """
164 try:167 try:
165 import gpgme168 import gpgme
166 return True169 return True
@@ -274,6 +277,7 @@
274 if isinstance(acceptable_keys_config, unicode):277 if isinstance(acceptable_keys_config, unicode):
275 acceptable_keys_config = str(acceptable_keys_config)278 acceptable_keys_config = str(acceptable_keys_config)
276 except UnicodeEncodeError:279 except UnicodeEncodeError:
280 #gpg Context.keylist(pattern) does not like unicode
277 raise errors.BzrCommandError('Only ASCII permitted in option names')281 raise errors.BzrCommandError('Only ASCII permitted in option names')
278282
279 if acceptable_keys_config is not None:283 if acceptable_keys_config is not None:
@@ -296,11 +300,14 @@
296 "No GnuPG key results for pattern: {}"300 "No GnuPG key results for pattern: {}"
297 ).format(pattern))301 ).format(pattern))
298302
299 def do_verifications(self, revisions, repository):303 def do_verifications(self, revisions, repository,
304 process_events_callback=None):
300 """do verifications on a set of revisions305 """do verifications on a set of revisions
301 306
302 :param revisions: list of revision ids to verify307 :param revisions: list of revision ids to verify
303 :param repository: repository object308 :param repository: repository object
309 :param process_events_callback: method to call for GUI frontends that
310 want to keep their UI refreshed
304 311
305 :return: count dictionary of results of each type,312 :return: count dictionary of results of each type,
306 result list for each revision,313 result list for each revision,
@@ -319,6 +326,8 @@
319 count[verification_result] += 1326 count[verification_result] += 1
320 if verification_result != SIGNATURE_VALID:327 if verification_result != SIGNATURE_VALID:
321 all_verifiable = False328 all_verifiable = False
329 if process_events_callback is not None:
330 process_events_callback()
322 return (count, result, all_verifiable)331 return (count, result, all_verifiable)
323332
324 def verbose_valid_message(self, result):333 def verbose_valid_message(self, result):
@@ -330,8 +339,8 @@
330 signers[uid] += 1339 signers[uid] += 1
331 result = []340 result = []
332 for uid, number in signers.items():341 for uid, number in signers.items():
333 result.append( i18n.ngettext("{0} signed {1} commit", 342 result.append( i18n.ngettext(u"{0} signed {1} commit",
334 "{0} signed {1} commits",343 u"{0} signed {1} commits",
335 number).format(uid, number) )344 number).format(uid, number) )
336 return result345 return result
337346
@@ -347,8 +356,8 @@
347 signers[authors] += 1356 signers[authors] += 1
348 result = []357 result = []
349 for authors, number in signers.items():358 for authors, number in signers.items():
350 result.append( i18n.ngettext("{0} commit by author {1}", 359 result.append( i18n.ngettext(u"{0} commit by author {1}",
351 "{0} commits by author {1}",360 u"{0} commits by author {1}",
352 number).format(number, authors) )361 number).format(number, authors) )
353 return result362 return result
354363
@@ -363,8 +372,8 @@
363 signers[authors] += 1372 signers[authors] += 1
364 result = []373 result = []
365 for authors, number in signers.items():374 for authors, number in signers.items():
366 result.append( i18n.ngettext("{0} commit by author {1}", 375 result.append( i18n.ngettext(u"{0} commit by author {1}",
367 "{0} commits by author {1}",376 u"{0} commits by author {1}",
368 number).format(number, authors) )377 number).format(number, authors) )
369 return result378 return result
370379
@@ -377,33 +386,33 @@
377 signers[fingerprint] += 1386 signers[fingerprint] += 1
378 result = []387 result = []
379 for fingerprint, number in signers.items():388 for fingerprint, number in signers.items():
380 result.append( i18n.ngettext("Unknown key {0} signed {1} commit", 389 result.append( i18n.ngettext(u"Unknown key {0} signed {1} commit",
381 "Unknown key {0} signed {1} commits",390 u"Unknown key {0} signed {1} commits",
382 number).format(fingerprint, number) )391 number).format(fingerprint, number) )
383 return result392 return result
384393
385 def valid_commits_message(self, count):394 def valid_commits_message(self, count):
386 """returns message for number of commits"""395 """returns message for number of commits"""
387 return i18n.gettext("{0} commits with valid signatures").format(396 return i18n.gettext(u"{0} commits with valid signatures").format(
388 count[SIGNATURE_VALID])397 count[SIGNATURE_VALID])
389398
390 def unknown_key_message(self, count):399 def unknown_key_message(self, count):
391 """returns message for number of commits"""400 """returns message for number of commits"""
392 return i18n.ngettext("{0} commit with unknown key",401 return i18n.ngettext(u"{0} commit with unknown key",
393 "{0} commits with unknown keys",402 u"{0} commits with unknown keys",
394 count[SIGNATURE_KEY_MISSING]).format(403 count[SIGNATURE_KEY_MISSING]).format(
395 count[SIGNATURE_KEY_MISSING])404 count[SIGNATURE_KEY_MISSING])
396405
397 def commit_not_valid_message(self, count):406 def commit_not_valid_message(self, count):
398 """returns message for number of commits"""407 """returns message for number of commits"""
399 return i18n.ngettext("{0} commit not valid",408 return i18n.ngettext(u"{0} commit not valid",
400 "{0} commits not valid",409 u"{0} commits not valid",
401 count[SIGNATURE_NOT_VALID]).format(410 count[SIGNATURE_NOT_VALID]).format(
402 count[SIGNATURE_NOT_VALID])411 count[SIGNATURE_NOT_VALID])
403412
404 def commit_not_signed_message(self, count):413 def commit_not_signed_message(self, count):
405 """returns message for number of commits"""414 """returns message for number of commits"""
406 return i18n.ngettext("{0} commit not signed",415 return i18n.ngettext(u"{0} commit not signed",
407 "{0} commits not signed",416 u"{0} commits not signed",
408 count[SIGNATURE_NOT_SIGNED]).format(417 count[SIGNATURE_NOT_SIGNED]).format(
409 count[SIGNATURE_NOT_SIGNED])418 count[SIGNATURE_NOT_SIGNED])