Merge lp:~benji/launchpadlib/keyring into lp:launchpadlib

Proposed by Benji York
Status: Merged
Merged at revision: 100
Proposed branch: lp:~benji/launchpadlib/keyring
Merge into: lp:launchpadlib
Diff against target: 464 lines (+330/-24)
3 files modified
src/launchpadlib/NEWS.txt (+5/-0)
src/launchpadlib/launchpad.py (+115/-21)
src/launchpadlib/tests/test_launchpad.py (+210/-3)
To merge this branch: bzr merge lp:~benji/launchpadlib/keyring
Reviewer Review Type Date Requested Status
Leonard Richardson (community) Needs Fixing
Review via email: mp+36365@code.launchpad.net

Description of the change

Make launchpadlib attempt to store/load authentication credentials
in/from the Gnome keyring.

To post a comment you must log in.
lp:~benji/launchpadlib/keyring updated
98. By Benji York <benji@benji-laptop>

use the more customary cStringIO instead of StringIO

99. By Benji York <benji@benji-laptop>

reword docstring

100. By Benji York <benji@benji-laptop>

add comment about replacing the socket module

Revision history for this message
Leonard Richardson (leonardr) wrote :

Looks good. I like the fake keyring. A few suggestions that I mentioned on IRC:

Add a context manager for setting and resetting the fake keyring.

See if you can move the keyring code into credentials.py.

Rework the docstring about the hierarchy of "places to look for/store credentials": we check the keyring first, then a provided file, then the default launchpadlib location.

And actually, it might make more sense to check the provided file *before* checking the keyring. (Though if the key isn't found, I think it still makes sense to write it to the keyring rather than the provided file.)

Also make sure to mention the change in the NEWS.

lp:~benji/launchpadlib/keyring updated
101. By Benji York <benji@benji-laptop>

use a context manager to encapsulate a reoccurring pattern

Revision history for this message
Leonard Richardson (leonardr) :
review: Approve
lp:~benji/launchpadlib/keyring updated
102. By Benji York <benji@benji-laptop>

a couple of small tweaks

103. By Benji York <benji@benji-laptop>

add NEWS

Revision history for this message
Robert Collins (lifeless) wrote :

This is spectacularly risky unless you keep per-application access
restrictions. I haven't checked the code to see if thats what you're
doing.

It's-not-paranoia-if-they-are-out-to-get-you'ly-yrs
Rob

Revision history for this message
Benji York (benji) wrote :

On Thu, Sep 23, 2010 at 5:25 AM, Robert Collins
<email address hidden> wrote:
> This is spectacularly risky unless you keep per-application access
> restrictions.

Can you unpack that a bit? I don't follow.
--
Benji York

Revision history for this message
Robert Collins (lifeless) wrote :

See the thread about this overall conceptual change on the mailling list.

Revision history for this message
Leonard Richardson (leonardr) wrote :

I agree that you should wait to land this until the overall change is resolved, just to avoid confusion. But there are, currently, no per-application access restrictions in launchpadlib. It just looks like there are. Tokens are stored in standard locations in files on disk, and any running application can look for a token that meets its needs and use it. This was something we planned to *fix* by moving the tokens into the GNOME keyring, but it turns out the GNOME keyring is no different in this regard.

IMO this change is a net gain for security, even if we don't make the larger change to the way tokens work in a desktop context. Storing OAuth tokens on disk means that if someone steals your hard drive, they can see your tokens, unless you encrypted your home directory. Storing tokens in a keyring ensures that they are always stored encrypted on disk.

Revision history for this message
Robert Collins (lifeless) wrote :

On Fri, Sep 24, 2010 at 9:09 AM, Leonard Richardson
<email address hidden> wrote:
> I agree that you should wait to land this until the overall change is resolved, just to avoid confusion. But there are, currently, no per-application access restrictions in launchpadlib. It just looks like there are. Tokens are stored in standard locations in files on disk, and any running application can look for a token that meets its needs and use it. This was something we planned to *fix* by moving the tokens into the GNOME keyring, but it turns out the GNOME keyring is no different in this regard.

Applications manually store their keys; they can handoff to a personal
wallet with different characteristics to gnome keyring.

> IMO this change is a net gain for security, even if we don't make the larger change to the way tokens work in a desktop context. Storing OAuth tokens on disk means that if someone steals your hard drive, they can see your tokens, unless you encrypted your home directory. Storing tokens in a keyring ensures that they are always stored encrypted on disk.

In the Ubuntu context we ship with per file encryption available by default.

Revision history for this message
Leonard Richardson (leonardr) wrote :

> Applications manually store their keys; they can handoff to a personal
> wallet with different characteristics to gnome keyring.

Applications *may* manually store their keys, but almost every one I've seen uses Launchpad.login_with(), which stores the key on disk.

This branch changes login_with() to store the keys in the GNOME keyring instead of on disk. Applications that don't use login_with() are still free to do whatever they want.

Revision history for this message
Leonard Richardson (leonardr) wrote :

I'm un-approving this branch because Sidnei da Silva on launchpad-dev mentioned the keyring package (http://pypi.python.org/pypi/keyring), a general keyring library that abstracts away the difference between the GNOME keyring, the KDE wallet, etc. I was planning to add KDE support in a separate branch, but let's make keyring a dependency of launchpadlib and start out with KDE support.

review: Needs Fixing

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/launchpadlib/NEWS.txt'
2--- src/launchpadlib/NEWS.txt 2010-08-23 19:51:55 +0000
3+++ src/launchpadlib/NEWS.txt 2010-09-22 21:58:38 +0000
4@@ -2,6 +2,11 @@
5 NEWS for launchpadlib
6 =====================
7
8+1.7.0 (2010-09-23)
9+==================
10+
11+- Store authorization tokens in the Gnome keyring, when available.
12+
13 1.6.5 (2010-08-23)
14 ==================
15
16
17=== modified file 'src/launchpadlib/launchpad.py'
18--- src/launchpadlib/launchpad.py 2010-08-23 19:51:55 +0000
19+++ src/launchpadlib/launchpad.py 2010-09-22 21:58:38 +0000
20@@ -22,7 +22,9 @@
21 ]
22
23 import os
24+import socket
25 import stat
26+import cStringIO
27 import urlparse
28
29 from lazr.uri import URI
30@@ -33,6 +35,13 @@
31 AuthorizeRequestTokenWithBrowser)
32 from launchpadlib import uris
33
34+# The Gnome keyring module (part of gtk) is optional
35+try:
36+ import gnomekeyring
37+except ImportError:
38+ gnomekeyring = None
39+
40+
41 # Import some constants for backwards compatibility. This way, old
42 # scripts that have 'from launchpad import EDGE_SERVICE_ROOT' will still
43 # work.
44@@ -96,6 +105,85 @@
45 collection_of = 'distribution'
46
47
48+def get_keyring_attr(consumer_name):
49+ token_name = '%s (launchpadlib on %s)' % (
50+ consumer_name, socket.gethostname())
51+ return {
52+ 'key-type': 'launchpadlib credentials',
53+ 'token-name': token_name,
54+ }
55+
56+
57+def query_keyring_for_credentials(consumer_name):
58+ """Load the credentials from the the Gnome keyring, if available."""
59+ if gnomekeyring is None:
60+ return None
61+ if not gnomekeyring.is_available():
62+ return None
63+
64+ try:
65+ items = gnomekeyring.find_items_sync(
66+ gnomekeyring.ITEM_GENERIC_SECRET, get_keyring_attr(consumer_name))
67+ except gnomekeyring.NoMatchError:
68+ return None
69+ except gnomekeyring.CancelledError:
70+ # The user pressed "Cancel" when prompted to unlock their keyring.
71+ return None
72+
73+ assert len(items) == 1, 'no more than one entry should ever match'
74+ credentials = Credentials()
75+ credentials.load(cStringIO.StringIO(items[0].secret))
76+ return credentials
77+
78+
79+def attempt_to_save_credentials_in_keyring(credentials, consumer_name):
80+ """Save the credentials to the the Gnome keyring, if available."""
81+ if gnomekeyring is None:
82+ return False
83+ if not gnomekeyring.is_available():
84+ return False
85+
86+ # Get the credentials as a string.
87+ sio = cStringIO.StringIO()
88+ credentials.save(sio)
89+ value = sio.getvalue()
90+
91+ # If the login keyring is locked (e.g., they have auto login enabled) a
92+ # dialog box prompting for their keyring password will be displayed.
93+ # TODO make this return False if the user cancels
94+ try:
95+ gnomekeyring.item_create_sync(
96+ 'login', gnomekeyring.ITEM_GENERIC_SECRET, consumer_name,
97+ get_keyring_attr(consumer_name), value, True)
98+ except gnomekeyring.CancelledError:
99+ # The user pressed "Cancel" when prompted to unlock their keyring.
100+ return False
101+
102+ return True
103+
104+
105+def save_credentials(credentials, consumer_credentials_path, consumer_name):
106+ if attempt_to_save_credentials_in_keyring(credentials, consumer_name):
107+ # It worked. Nothing more to do.
108+ return
109+ else:
110+ # We couldn't get the credentials into the key
111+ credentials.save_to_path(consumer_credentials_path)
112+ os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
113+
114+
115+def load_credentials(consumer_name, consumer_credentials_path):
116+ keyring_credentials = query_keyring_for_credentials(consumer_name)
117+ if keyring_credentials is not None:
118+ return keyring_credentials
119+ elif os.path.exists(consumer_credentials_path):
120+ credentials = Credentials.load_from_path(consumer_credentials_path)
121+ attempt_to_save_credentials_in_keyring(credentials, consumer_name)
122+ return credentials
123+ else:
124+ return None
125+
126+
127 class Launchpad(ServiceRoot):
128 """Root Launchpad API class.
129
130@@ -240,17 +328,17 @@
131 """Log in to Launchpad with possibly cached credentials.
132
133 This is a convenience method for either setting up new login
134- credentials, or re-using existing ones. When a login token is generated
135- using this method, the resulting credentials will be saved in
136- `credentials_file`, or if not given, into the `launchpadlib_dir`
137- directory. If the same `credentials_file`/`launchpadlib_dir` is passed
138- in a second time, the credentials in for the consumer will be used
139- automatically.
140-
141- Each consumer has their own credentials per service root in
142- `launchpadlib_dir`. `launchpadlib_dir` is also used for caching
143- fetched objects. The cache is per service root, and shared by
144- all consumers.
145+ credentials, or re-using existing ones. When a login token is
146+ generated using this method, the resulting credentials will be stored
147+ in one of these locations (given in order of priority): the Gnome
148+ keyring (if available), `credentials_file` (if provided), or as a last
149+ resort the `launchpadlib_dir` directory.
150+
151+ Subsequent calls to this method will load the credentials from one of
152+ the aformentioned locations in the same priority order.
153+
154+ `launchpadlib_dir` is also used for caching fetched objects. The cache
155+ is per service root, and shared by all consumers.
156
157 See `Launchpad.get_token_and_login()` for more information about
158 how new tokens are generated.
159@@ -274,30 +362,35 @@
160 (service_root, launchpadlib_dir, cache_path,
161 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
162 credentials_path = os.path.join(service_root_dir, 'credentials')
163+
164 if not os.path.exists(credentials_path):
165 os.makedirs(credentials_path, 0700)
166+
167 if credentials_file is None:
168 consumer_credentials_path = os.path.join(credentials_path,
169 consumer_name)
170 else:
171 consumer_credentials_path = credentials_file
172- if os.path.exists(consumer_credentials_path):
173- credentials = Credentials.load_from_path(
174- consumer_credentials_path)
175- launchpad = cls(
176- credentials, service_root=service_root, cache=cache_path,
177- timeout=timeout, proxy_info=proxy_info, version=version)
178- else:
179+
180+ credentials = load_credentials(
181+ consumer_name, consumer_credentials_path)
182+
183+ if credentials is None:
184 launchpad = cls.get_token_and_login(
185 consumer_name, service_root=service_root, cache=cache_path,
186 timeout=timeout, proxy_info=proxy_info,
187 authorizer_class=authorizer_class,
188 allow_access_levels=allow_access_levels,
189 max_failed_attempts=max_failed_attempts, version=version)
190+ # New credentials were created, so save them for future use.
191 if launchpad is not None:
192- launchpad.credentials.save_to_path(consumer_credentials_path)
193- os.chmod(
194- consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
195+ save_credentials(
196+ launchpad.credentials, consumer_credentials_path,
197+ consumer_name)
198+ else:
199+ launchpad = cls(
200+ credentials, service_root=service_root, cache=cache_path,
201+ timeout=timeout, proxy_info=proxy_info, version=version)
202 return launchpad
203
204 @classmethod
205@@ -336,3 +429,4 @@
206 if not os.path.exists(cache_path):
207 os.makedirs(cache_path, 0700)
208 return (service_root, launchpadlib_dir, cache_path, service_root_dir)
209+
210
211=== modified file 'src/launchpadlib/tests/test_launchpad.py'
212--- src/launchpadlib/tests/test_launchpad.py 2010-08-23 19:51:55 +0000
213+++ src/launchpadlib/tests/test_launchpad.py 2010-09-22 21:58:38 +0000
214@@ -20,16 +20,28 @@
215
216 import os
217 import shutil
218+import socket
219 import stat
220 import tempfile
221+import textwrap
222 import unittest
223+from contextlib import contextmanager
224
225 from lazr.restfulclient.resource import ServiceRoot
226
227 from launchpadlib.credentials import (
228- AccessToken, AuthorizeRequestTokenWithBrowser, Credentials)
229-from launchpadlib.launchpad import Launchpad
230+ AccessToken,
231+ AuthorizeRequestTokenWithBrowser,
232+ Credentials,
233+ )
234+from launchpadlib.launchpad import (
235+ Launchpad,
236+ attempt_to_save_credentials_in_keyring,
237+ get_keyring_attr,
238+ query_keyring_for_credentials,
239+ )
240 from launchpadlib import uris
241+import launchpadlib.launchpad
242
243 class NoNetworkLaunchpad(Launchpad):
244 """A Launchpad instance for tests with no network access.
245@@ -152,9 +164,15 @@
246 """Tests for Launchpad.login_with()."""
247
248 def setUp(self):
249+ # For these tests we want to disable retrieving or storing credentials
250+ # in the Gnome keyring.
251+ self._saved_gnomekeyring = launchpadlib.launchpad.gnomekeyring
252+ launchpadlib.launchpad.gnomekeyring = None
253 self.temp_dir = tempfile.mkdtemp()
254
255 def tearDown(self):
256+ # Restore the gnomekeyring module that we disabled in setUp.
257+ launchpadlib.launchpad.gnomekeyring = self._saved_gnomekeyring
258 shutil.rmtree(self.temp_dir)
259
260 def test_dirs_created(self):
261@@ -383,7 +401,7 @@
262 ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo')
263
264 def test_separate_credentials_file(self):
265- my_credentials_path = os.path.join(self.temp_dir, 'my_creds')
266+ my_credentials_path = os.path.join(self.temp_dir, 'my_creds')
267 launchpad = NoNetworkLaunchpad.login_with(
268 'app name', launchpadlib_dir=self.temp_dir,
269 credentials_file=my_credentials_path,
270@@ -405,5 +423,194 @@
271 self.assertFalse(launchpad.get_token_and_login_called)
272
273
274+class FauxSocketModule:
275+ """A socket module replacement that provides a fake hostname."""
276+
277+ def gethostname(self):
278+ return 'HOSTNAME'
279+
280+
281+class FauxCancelledError(Exception):
282+ """A substitute for gnomekeyring.CancelledError."""
283+
284+
285+class FauxNoMatchError(Exception):
286+ """A substitute for gnomekeyring.NoMatchError."""
287+
288+
289+class BaseFauxKeyringModule:
290+ """A base to build fake keyring modules with various behaviors."""
291+
292+ CancelledError = FauxCancelledError
293+ NoMatchError = FauxNoMatchError
294+ ITEM_GENERIC_SECRET = 42
295+
296+ def is_available(self):
297+ return True
298+
299+
300+class UnavailableKeyring:
301+ """A keyring that behaves as if the Gnome keyring daemon is not running."""
302+ def is_available(self):
303+ return False
304+
305+
306+class CancelledKeyring(BaseFauxKeyringModule):
307+ """A keyring that behaves as if the user canceled password entry."""
308+
309+ def find_items_sync(self, type, attr):
310+ raise self.CancelledError
311+
312+ def item_create_sync(self, *args):
313+ raise self.CancelledError
314+
315+
316+class NoCredentialsKeyring(BaseFauxKeyringModule):
317+ """A keyring that doesn't contain the credentials."""
318+
319+ def find_items_sync(self, type, attr):
320+ raise self.NoMatchError
321+
322+
323+class GoodKeyring(BaseFauxKeyringModule):
324+ """A keyring that behaves as if the load and store operations succeed."""
325+
326+ def item_create_sync(self, *args):
327+ pass
328+
329+ def find_items_sync(self, type, attr):
330+ credentials_data = textwrap.dedent("""\
331+ [1]
332+ consumer_secret = CONSUMER SECRET
333+ access_token = ACCESS TOKEN
334+ consumer_key = CONSUMER NAME
335+ access_secret = ACCESS SECRET""")
336+
337+ class FauxKeyringItem:
338+ secret = credentials_data
339+
340+ return [FauxKeyringItem()]
341+
342+
343+@contextmanager
344+def fake_keyring(fake):
345+ original = launchpadlib.launchpad.gnomekeyring
346+ launchpadlib.launchpad.gnomekeyring = fake
347+ yield
348+ launchpadlib.launchpad.gnomekeyring = original
349+
350+
351+class TestLoadFromKeyring(unittest.TestCase):
352+
353+ def setUp(self):
354+ # launchpadlib.launchpad uses the socket module to look up the
355+ # hostname, obviously that can vary so we replace the socket module
356+ # with a fake that returns a fake hostname.
357+ launchpadlib.launchpad.socket = FauxSocketModule()
358+ self.temp_dir = tempfile.mkdtemp()
359+
360+ def tearDown(self):
361+ shutil.rmtree(self.temp_dir)
362+ launchpadlib.launchpad.socket = socket
363+
364+ def test_get_keyring_attr(self):
365+ # Gnome keyring entries are identified by a set of "attributes". We
366+ # construct the attributes such that each application will have a
367+ # unique entry that shouldn't conflict with an applications other uses
368+ # of the keyring.
369+ self.assertEqual(
370+ get_keyring_attr('CONSUMER NAME'),
371+ {'key-type': 'launchpadlib credentials',
372+ 'token-name': 'CONSUMER NAME (launchpadlib on HOSTNAME)'})
373+
374+ def test_no_keyring_module(self):
375+ # If the gnomekeyring module cannot be imported (and is therefore set
376+ # to None) an attempt to load credentials from the keyring will return
377+ # None instead.
378+ with fake_keyring(None):
379+ self.assertEqual(
380+ query_keyring_for_credentials('CONSUMER NAME'), None)
381+
382+ def test_no_keyring_available(self):
383+ # If the gnomekeyring module can be imported but the keyring daemon is
384+ # not running, an attempt to load credentials from the keyring will
385+ # return None instead.
386+
387+ with fake_keyring(UnavailableKeyring()):
388+ self.assertEqual(
389+ query_keyring_for_credentials('CONSUMER NAME'), None)
390+
391+ def test_keyring_does_not_have_credentials(self):
392+ # If the keyring doesn't have the credentials stored in it, an attempt
393+ # to load credentials from the keyring will return None.
394+ with fake_keyring(NoCredentialsKeyring()):
395+ self.assertEqual(
396+ query_keyring_for_credentials('CONSUMER NAME'), None)
397+
398+ def test_user_canceled_password_prompt(self):
399+ # If the user cancels the dialog asking for their password to unlock
400+ # the keyring the keyring will return None.
401+ with fake_keyring(CancelledKeyring()):
402+ self.assertEqual(
403+ query_keyring_for_credentials('CONSUMER NAME'), None)
404+
405+ def test_successful_credentials_lookup(self):
406+ # If serialized credentials are found in the keyring, a Credentials
407+ # object is constructed and returned.
408+ with fake_keyring(GoodKeyring()):
409+ credentials = query_keyring_for_credentials('CONSUMER NAME')
410+ self.assertNotEqual(credentials, None)
411+ self.assertEqual(
412+ credentials.user_agent_params['oauth_consumer'],
413+ 'CONSUMER NAME')
414+ self.assertEqual(credentials.access_token.key, 'ACCESS TOKEN')
415+
416+
417+class FauxCredentials:
418+ """A stand-in for the launchpadlib Credentials object."""
419+
420+ def save(self, writable_file):
421+ writable_file.write('faux credentials')
422+
423+
424+class TestSaveToKeyring(unittest.TestCase):
425+
426+ def setUp(self):
427+ # launchpadlib.launchpad uses the socket module to look up the
428+ # hostname, obviously that can vary so we replace the socket module
429+ # with a fake that returns a fake hostname.
430+ launchpadlib.launchpad.socket = FauxSocketModule()
431+ self.temp_dir = tempfile.mkdtemp()
432+
433+ def tearDown(self):
434+ shutil.rmtree(self.temp_dir)
435+ launchpadlib.launchpad.socket = socket
436+
437+ def test_no_keyring_available(self):
438+ # If the gnomekeyring module can be imported but the keyring daemon is
439+ # not running, an attempt to save credentials from the keyring will
440+ # return False to indicate that the data was not saved.
441+ with fake_keyring(UnavailableKeyring()):
442+ result = attempt_to_save_credentials_in_keyring(
443+ None, 'CONSUMER NAME')
444+ self.assertEqual(result, False)
445+
446+ def test_user_canceled_password_prompt(self):
447+ # If the user cancels the dialog asking for their password to unlock
448+ # the keyring the credentials will not be saved in the keyring.
449+ with fake_keyring(CancelledKeyring()):
450+ result = attempt_to_save_credentials_in_keyring(
451+ FauxCredentials(), 'CONSUMER NAME')
452+ self.assertEqual(result, False)
453+
454+ def test_successful_save(self):
455+ # If the credentials were successfully written to the keyring, True is
456+ # returned.
457+ with fake_keyring(GoodKeyring()):
458+ result = attempt_to_save_credentials_in_keyring(
459+ FauxCredentials(), 'CONSUMER NAME')
460+ self.assertEqual(result, True)
461+
462+
463 def test_suite():
464 return unittest.TestLoader().loadTestsFromName(__name__)

Subscribers

People subscribed via source and target branches