Merge lp:~cjwatson/launchpad/snap-store-secrets-encrypt into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 19024
Proposed branch: lp:~cjwatson/launchpad/snap-store-secrets-encrypt
Merge into: lp:launchpad
Diff against target: 757 lines (+517/-19)
9 files modified
lib/lp/services/config/schema-lazr.conf (+10/-0)
lib/lp/services/crypto/interfaces.py (+56/-0)
lib/lp/services/crypto/model.py (+118/-0)
lib/lp/services/crypto/tests/test_model.py (+83/-0)
lib/lp/snappy/configure.zcml (+8/-0)
lib/lp/snappy/model/snap.py (+35/-2)
lib/lp/snappy/model/snapstoreclient.py (+55/-10)
lib/lp/snappy/tests/test_snap.py (+34/-0)
lib/lp/snappy/tests/test_snapstoreclient.py (+118/-7)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-store-secrets-encrypt
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+369428@code.launchpad.net

Commit message

Support encrypting snap store discharge macaroons at rest.

Description of the change

We should be encrypting sensitive data such as authentication tokens at rest in the database, and this adds basic general support for doing that and sets it up for the SSO discharge macaroons used for uploading snaps to the store.

These discharge macaroons are refreshed every so often, and that will encrypt the refreshed tokens if a suitable public key is configured.

The JSON-serialised encrypted format includes the public key in order to support future key rotation (by configuring multiple key pairs and using the corresponding private key for decryption), although I haven't actually written the code for that yet.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/config/schema-lazr.conf'
2--- lib/lp/services/config/schema-lazr.conf 2019-07-11 13:27:09 +0000
3+++ lib/lp/services/config/schema-lazr.conf 2019-08-21 09:32:48 +0000
4@@ -1896,6 +1896,16 @@
5 # The store's search URL endpoint.
6 store_search_url: none
7
8+# Base64-encoded NaCl private key for decrypting store upload tokens.
9+# This should only be set in secret overlays on systems that need to perform
10+# store uploads on behalf of users.
11+# datatype: string
12+store_secrets_private_key: none
13+
14+# Base64-encoded NaCl public key for encrypting store upload tokens.
15+# datatype: string
16+store_secrets_public_key: none
17+
18 [process-job-source-groups]
19 # This section is used by cronscripts/process-job-source-groups.py.
20 dbuser: process-job-source-groups
21
22=== added directory 'lib/lp/services/crypto'
23=== added file 'lib/lp/services/crypto/__init__.py'
24=== added file 'lib/lp/services/crypto/interfaces.py'
25--- lib/lp/services/crypto/interfaces.py 1970-01-01 00:00:00 +0000
26+++ lib/lp/services/crypto/interfaces.py 2019-08-21 09:32:48 +0000
27@@ -0,0 +1,56 @@
28+# Copyright 2019 Canonical Ltd. This software is licensed under the
29+# GNU Affero General Public License version 3 (see the file LICENSE).
30+
31+"""Interface to data encrypted at rest using configured keys."""
32+
33+from __future__ import absolute_import, print_function, unicode_literals
34+
35+__metaclass__ = type
36+__all__ = [
37+ 'CryptoError',
38+ 'IEncryptedContainer',
39+ ]
40+
41+from zope.interface import (
42+ Attribute,
43+ Interface,
44+ )
45+
46+
47+class CryptoError(Exception):
48+ pass
49+
50+
51+class IEncryptedContainer(Interface):
52+ """Interface to a container that can encrypt and decrypt data."""
53+
54+ can_encrypt = Attribute(
55+ "True iff this container has the configuration it needs to encrypt "
56+ "data.")
57+
58+ def encrypt(data):
59+ """Encrypt a blob of data to a JSON-serialisable form.
60+
61+ This includes the public key to ease future key rotation.
62+
63+ :param data: An unencrypted byte string to encrypt.
64+ :return: A tuple of (base64-encoded public key, base64-encoded
65+ encrypted text string).
66+ :raises RuntimeError: if no public key is configured for this
67+ container.
68+ """
69+
70+ can_decrypt = Attribute(
71+ "True iff this container has the configuration it needs to decrypt "
72+ "data.")
73+
74+ def decrypt(data):
75+ """Decrypt data that was encrypted by L{encrypt}.
76+
77+ :param data: A tuple of (base64-encoded public key, base64-encoded
78+ encrypted text string) to decrypt.
79+ :return: An unencrypted byte string.
80+ :raises ValueError: if no private key is configured for this container
81+ that corresponds to the requested public key.
82+ :raises CryptoError: if decryption failed.
83+ """
84
85=== added file 'lib/lp/services/crypto/model.py'
86--- lib/lp/services/crypto/model.py 1970-01-01 00:00:00 +0000
87+++ lib/lp/services/crypto/model.py 2019-08-21 09:32:48 +0000
88@@ -0,0 +1,118 @@
89+# Copyright 2019 Canonical Ltd. This software is licensed under the
90+# GNU Affero General Public License version 3 (see the file LICENSE).
91+
92+"""A container for data encrypted at rest using configured keys."""
93+
94+from __future__ import absolute_import, print_function, unicode_literals
95+
96+__metaclass__ = type
97+__all__ = [
98+ 'NaClEncryptedContainerBase',
99+ ]
100+
101+import base64
102+
103+from nacl.exceptions import CryptoError as NaClCryptoError
104+from nacl.public import (
105+ PrivateKey,
106+ PublicKey,
107+ SealedBox,
108+ )
109+import six
110+from zope.interface import implementer
111+
112+from lp.services.crypto.interfaces import (
113+ CryptoError,
114+ IEncryptedContainer,
115+ )
116+
117+
118+@implementer(IEncryptedContainer)
119+class NaClEncryptedContainerBase:
120+ """A container that can encrypt and decrypt data using NaCl.
121+
122+ See `IEncryptedContainer`.
123+ """
124+
125+ @property
126+ def public_key_bytes(self):
127+ """The serialised public key as a byte string.
128+
129+ Concrete implementations must provide this.
130+ """
131+ raise NotImplementedError
132+
133+ @property
134+ def public_key(self):
135+ """The public key as a L{nacl.public.PublicKey}."""
136+ if self.public_key_bytes is not None:
137+ try:
138+ return PublicKey(self.public_key_bytes)
139+ except NaClCryptoError as e:
140+ six.raise_from(CryptoError(str(e)), e)
141+ else:
142+ return None
143+
144+ @property
145+ def can_encrypt(self):
146+ try:
147+ return self.public_key is not None
148+ except CryptoError:
149+ return False
150+
151+ def encrypt(self, data):
152+ """See `IEncryptedContainer`."""
153+ if self.public_key is None:
154+ raise RuntimeError("No public key configured")
155+ try:
156+ data_encrypted = SealedBox(self.public_key).encrypt(data)
157+ except NaClCryptoError as e:
158+ six.raise_from(CryptoError(str(e)), e)
159+ return (
160+ base64.b64encode(self.public_key_bytes).decode("UTF-8"),
161+ base64.b64encode(data_encrypted).decode("UTF-8"))
162+
163+ @property
164+ def private_key_bytes(self):
165+ """The serialised private key as a byte string.
166+
167+ Concrete implementations must provide this.
168+ """
169+ raise NotImplementedError
170+
171+ @property
172+ def private_key(self):
173+ """The private key as a L{nacl.public.PrivateKey}."""
174+ if self.private_key_bytes is not None:
175+ try:
176+ return PrivateKey(self.private_key_bytes)
177+ except NaClCryptoError as e:
178+ six.raise_from(CryptoError(str(e)), e)
179+ else:
180+ return None
181+
182+ @property
183+ def can_decrypt(self):
184+ try:
185+ return self.private_key is not None
186+ except CryptoError:
187+ return False
188+
189+ def decrypt(self, data):
190+ """See `IEncryptedContainer`."""
191+ public_key, encrypted = data
192+ try:
193+ public_key_bytes = base64.b64decode(public_key.encode("UTF-8"))
194+ encrypted_bytes = base64.b64decode(encrypted.encode("UTF-8"))
195+ except TypeError as e:
196+ six.raise_from(CryptoError(str(e)), e)
197+ if public_key_bytes != self.public_key_bytes:
198+ raise ValueError(
199+ "Public key %r does not match configured public key %r" %
200+ (public_key_bytes, self.public_key_bytes))
201+ if self.private_key is None:
202+ raise ValueError("No private key configured")
203+ try:
204+ return SealedBox(self.private_key).decrypt(encrypted_bytes)
205+ except NaClCryptoError as e:
206+ six.raise_from(CryptoError(str(e)), e)
207
208=== added directory 'lib/lp/services/crypto/tests'
209=== added file 'lib/lp/services/crypto/tests/__init__.py'
210=== added file 'lib/lp/services/crypto/tests/test_model.py'
211--- lib/lp/services/crypto/tests/test_model.py 1970-01-01 00:00:00 +0000
212+++ lib/lp/services/crypto/tests/test_model.py 2019-08-21 09:32:48 +0000
213@@ -0,0 +1,83 @@
214+# Copyright 2019 Canonical Ltd. This software is licensed under the
215+# GNU Affero General Public License version 3 (see the file LICENSE).
216+
217+"""Tests for encrypted data containers."""
218+
219+from __future__ import absolute_import, print_function, unicode_literals
220+
221+__metaclass__ = type
222+
223+from nacl.public import PrivateKey
224+
225+from lp.services.crypto.interfaces import CryptoError
226+from lp.services.crypto.model import NaClEncryptedContainerBase
227+from lp.testing import TestCase
228+from lp.testing.layers import ZopelessLayer
229+
230+
231+class FakeEncryptedContainer(NaClEncryptedContainerBase):
232+
233+ def __init__(self, public_key_bytes, private_key_bytes=None):
234+ self._public_key_bytes = public_key_bytes
235+ self._private_key_bytes = private_key_bytes
236+
237+ @property
238+ def public_key_bytes(self):
239+ return self._public_key_bytes
240+
241+ @property
242+ def private_key_bytes(self):
243+ return self._private_key_bytes
244+
245+
246+class TestNaClEncryptedContainerBase(TestCase):
247+
248+ layer = ZopelessLayer
249+
250+ def test_public_key_valid(self):
251+ public_key = PrivateKey.generate().public_key
252+ container = FakeEncryptedContainer(bytes(public_key))
253+ self.assertEqual(public_key, container.public_key)
254+ self.assertTrue(container.can_encrypt)
255+
256+ def test_public_key_invalid(self):
257+ container = FakeEncryptedContainer(b"nonsense")
258+ self.assertRaises(CryptoError, getattr, container, "public_key")
259+ self.assertFalse(container.can_encrypt)
260+
261+ def test_public_key_unset(self):
262+ container = FakeEncryptedContainer(None)
263+ self.assertIsNone(container.public_key)
264+ self.assertFalse(container.can_encrypt)
265+
266+ def test_encrypt_without_private_key(self):
267+ # Encryption only requires the public key, not the private key.
268+ public_key = PrivateKey.generate().public_key
269+ container = FakeEncryptedContainer(bytes(public_key))
270+ self.assertIsNotNone(container.encrypt(b"plaintext"))
271+
272+ def test_private_key_valid(self):
273+ private_key = PrivateKey.generate()
274+ container = FakeEncryptedContainer(
275+ bytes(private_key.public_key), bytes(private_key))
276+ self.assertEqual(private_key, container.private_key)
277+ self.assertTrue(container.can_decrypt)
278+
279+ def test_private_key_invalid(self):
280+ public_key = PrivateKey.generate().public_key
281+ container = FakeEncryptedContainer(bytes(public_key), b"nonsense")
282+ self.assertRaises(CryptoError, getattr, container, "private_key")
283+ self.assertFalse(container.can_decrypt)
284+
285+ def test_private_key_unset(self):
286+ public_key = PrivateKey.generate().public_key
287+ container = FakeEncryptedContainer(bytes(public_key), None)
288+ self.assertIsNone(container.private_key)
289+ self.assertFalse(container.can_decrypt)
290+
291+ def test_encrypt_decrypt(self):
292+ private_key = PrivateKey.generate()
293+ container = FakeEncryptedContainer(
294+ bytes(private_key.public_key), bytes(private_key))
295+ self.assertEqual(
296+ b"plaintext", container.decrypt(container.encrypt(b"plaintext")))
297
298=== modified file 'lib/lp/snappy/configure.zcml'
299--- lib/lp/snappy/configure.zcml 2019-03-12 19:18:19 +0000
300+++ lib/lp/snappy/configure.zcml 2019-08-21 09:32:48 +0000
301@@ -49,6 +49,14 @@
302 interface="lp.snappy.interfaces.snap.ISnapBuildRequest" />
303 </class>
304
305+ <!-- SnapStoreSecretsEncryptedContainer -->
306+ <securedutility
307+ class="lp.snappy.model.snap.SnapStoreSecretsEncryptedContainer"
308+ provides="lp.services.crypto.interfaces.IEncryptedContainer"
309+ name="snap-store-secrets">
310+ <allow interface="lp.services.crypto.interfaces.IEncryptedContainer"/>
311+ </securedutility>
312+
313 <!-- SnapBuild -->
314 <class class="lp.snappy.model.snapbuild.SnapBuild">
315 <require
316
317=== modified file 'lib/lp/snappy/model/snap.py'
318--- lib/lp/snappy/model/snap.py 2019-07-15 17:15:25 +0000
319+++ lib/lp/snappy/model/snap.py 2019-08-21 09:32:48 +0000
320@@ -6,6 +6,7 @@
321 'Snap',
322 ]
323
324+import base64
325 from collections import OrderedDict
326 from datetime import (
327 datetime,
328@@ -100,6 +101,8 @@
329 from lp.registry.model.series import ACTIVE_STATUSES
330 from lp.registry.model.teammembership import TeamParticipation
331 from lp.services.config import config
332+from lp.services.crypto.interfaces import IEncryptedContainer
333+from lp.services.crypto.model import NaClEncryptedContainerBase
334 from lp.services.database.bulk import load_related
335 from lp.services.database.constants import (
336 DEFAULT,
337@@ -602,9 +605,18 @@
338 "beginAuthorization must be called before "
339 "completeAuthorization.")
340 if discharge_macaroon is not None:
341- self.store_secrets["discharge"] = discharge_macaroon
342+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
343+ if container.can_encrypt:
344+ self.store_secrets["discharge_encrypted"] = (
345+ removeSecurityProxy(container.encrypt(
346+ discharge_macaroon.encode("UTF-8"))))
347+ self.store_secrets.pop("discharge", None)
348+ else:
349+ self.store_secrets["discharge"] = discharge_macaroon
350+ self.store_secrets.pop("discharge_encrypted", None)
351 else:
352 self.store_secrets.pop("discharge", None)
353+ self.store_secrets.pop("discharge_encrypted", None)
354
355 @property
356 def can_upload_to_store(self):
357@@ -617,7 +629,8 @@
358 return False
359 root_macaroon = Macaroon.deserialize(self.store_secrets["root"])
360 if (self.extractSSOCaveats(root_macaroon) and
361- "discharge" not in self.store_secrets):
362+ "discharge" not in self.store_secrets and
363+ "discharge_encrypted" not in self.store_secrets):
364 return False
365 return True
366
367@@ -1415,3 +1428,23 @@
368 def empty_list(self):
369 """See `ISnapSet`."""
370 return []
371+
372+
373+@implementer(IEncryptedContainer)
374+class SnapStoreSecretsEncryptedContainer(NaClEncryptedContainerBase):
375+
376+ @property
377+ def public_key_bytes(self):
378+ if config.snappy.store_secrets_public_key is not None:
379+ return base64.b64decode(
380+ config.snappy.store_secrets_public_key.encode("UTF-8"))
381+ else:
382+ return None
383+
384+ @property
385+ def private_key_bytes(self):
386+ if config.snappy.store_secrets_private_key is not None:
387+ return base64.b64decode(
388+ config.snappy.store_secrets_private_key.encode("UTF-8"))
389+ else:
390+ return None
391
392=== modified file 'lib/lp/snappy/model/snapstoreclient.py'
393--- lib/lp/snappy/model/snapstoreclient.py 2019-07-29 10:52:35 +0000
394+++ lib/lp/snappy/model/snapstoreclient.py 2019-08-21 09:32:48 +0000
395@@ -30,6 +30,10 @@
396 from zope.security.proxy import removeSecurityProxy
397
398 from lp.services.config import config
399+from lp.services.crypto.interfaces import (
400+ CryptoError,
401+ IEncryptedContainer,
402+ )
403 from lp.services.features import getFeatureFlag
404 from lp.services.memcache.interfaces import IMemcacheClient
405 from lp.services.scripts import log
406@@ -154,6 +158,45 @@
407 return r
408
409
410+def _get_discharge_macaroon_raw(snap):
411+ """Get the serialised discharge macaroon for a snap, if any.
412+
413+ This copes with either unencrypted (the historical default) or encrypted
414+ macaroons.
415+ """
416+ if snap.store_secrets is None:
417+ raise AssertionError("snap.store_secrets is None")
418+ if "discharge_encrypted" in snap.store_secrets:
419+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
420+ try:
421+ return container.decrypt(
422+ snap.store_secrets["discharge_encrypted"]).decode("UTF-8")
423+ except CryptoError as e:
424+ raise UnauthorizedUploadResponse(
425+ "Failed to decrypt discharge macaroon: %s" % e)
426+ else:
427+ return snap.store_secrets.get("discharge")
428+
429+
430+def _set_discharge_macaroon_raw(snap, discharge_macaroon_raw):
431+ """Set the serialised discharge macaroon for a snap.
432+
433+ The macaroon is encrypted if possible.
434+ """
435+ # Set a new dict here to avoid problems with security proxies.
436+ new_secrets = dict(snap.store_secrets)
437+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
438+ if container.can_encrypt:
439+ new_secrets["discharge_encrypted"] = (
440+ removeSecurityProxy(container.encrypt(
441+ discharge_macaroon_raw.encode("UTF-8"))))
442+ new_secrets.pop("discharge", None)
443+ else:
444+ new_secrets["discharge"] = discharge_macaroon_raw
445+ new_secrets.pop("discharge_encrypted", None)
446+ snap.store_secrets = new_secrets
447+
448+
449 # Hardcoded fallback.
450 _default_store_channels = [
451 {"name": "candidate", "display_name": "Candidate"},
452@@ -257,6 +300,7 @@
453 snap = snapbuild.snap
454 assert config.snappy.store_url is not None
455 assert snap.store_name is not None
456+ assert snap.store_secrets is not None
457 assert snapbuild.date_started is not None
458 upload_url = urlappend(config.snappy.store_url, "dev/api/snap-push/")
459 data = {
460@@ -276,12 +320,11 @@
461 # XXX cjwatson 2016-05-09: This should add timeline information, but
462 # that's currently difficult in jobs.
463 try:
464- assert snap.store_secrets is not None
465 response = urlfetch(
466 upload_url, method="POST", json=data,
467 auth=MacaroonAuth(
468 snap.store_secrets["root"],
469- snap.store_secrets.get("discharge")))
470+ _get_discharge_macaroon_raw(snap)))
471 response_data = response.json()
472 return response_data["status_details_url"]
473 except requests.HTTPError as e:
474@@ -312,18 +355,20 @@
475 assert snap.store_secrets is not None
476 refresh_url = urlappend(
477 config.launchpad.openid_provider_root, "api/v2/tokens/refresh")
478- data = {"discharge_macaroon": snap.store_secrets["discharge"]}
479+ discharge_macaroon_raw = _get_discharge_macaroon_raw(snap)
480+ if discharge_macaroon_raw is None:
481+ raise UnauthorizedUploadResponse(
482+ "Tried to refresh discharge for snap with no discharge "
483+ "macaroon")
484+ data = {"discharge_macaroon": discharge_macaroon_raw}
485 try:
486 response = urlfetch(refresh_url, method="POST", json=data)
487- response_data = response.json()
488- if "discharge_macaroon" not in response_data:
489- raise BadRefreshResponse(response.text)
490- # Set a new dict here to avoid problems with security proxies.
491- new_secrets = dict(snap.store_secrets)
492- new_secrets["discharge"] = response_data["discharge_macaroon"]
493- snap.store_secrets = new_secrets
494 except requests.HTTPError as e:
495 raise cls._makeSnapStoreError(BadRefreshResponse, e)
496+ response_data = response.json()
497+ if "discharge_macaroon" not in response_data:
498+ raise BadRefreshResponse(response.text)
499+ _set_discharge_macaroon_raw(snap, response_data["discharge_macaroon"])
500
501 @classmethod
502 def refreshIfNecessary(cls, snap, f, *args, **kwargs):
503
504=== modified file 'lib/lp/snappy/tests/test_snap.py'
505--- lib/lp/snappy/tests/test_snap.py 2019-07-15 17:15:25 +0000
506+++ lib/lp/snappy/tests/test_snap.py 2019-08-21 09:32:48 +0000
507@@ -7,6 +7,7 @@
508
509 __metaclass__ = type
510
511+import base64
512 from datetime import (
513 datetime,
514 timedelta,
515@@ -21,6 +22,7 @@
516 MockPatch,
517 )
518 import iso8601
519+from nacl.public import PrivateKey
520 from pymacaroons import Macaroon
521 import pytz
522 import responses
523@@ -67,6 +69,7 @@
524 from lp.registry.interfaces.pocket import PackagePublishingPocket
525 from lp.registry.interfaces.series import SeriesStatus
526 from lp.services.config import config
527+from lp.services.crypto.interfaces import IEncryptedContainer
528 from lp.services.database.constants import (
529 ONE_DAY_AGO,
530 UTC_NOW,
531@@ -3232,6 +3235,37 @@
532 with person_logged_in(self.person):
533 self.assertEqual({"root": "dummy-root"}, snap.store_secrets)
534
535+ def test_completeAuthorization_encrypted(self):
536+ private_key = PrivateKey.generate()
537+ self.pushConfig(
538+ "snappy",
539+ store_secrets_public_key=base64.b64encode(
540+ bytes(private_key.public_key)).decode("UTF-8"))
541+ with admin_logged_in():
542+ snappy_series = self.factory.makeSnappySeries()
543+ snap = self.factory.makeSnap(
544+ registrant=self.person, store_upload=True,
545+ store_series=snappy_series,
546+ store_name=self.factory.getUniqueUnicode(),
547+ store_secrets={"root": "dummy-root"})
548+ snap_url = api_url(snap)
549+ logout()
550+ response = self.webservice.named_post(
551+ snap_url, "completeAuthorization",
552+ discharge_macaroon="dummy-discharge")
553+ self.assertEqual(200, response.status)
554+ self.pushConfig(
555+ "snappy",
556+ store_secrets_private_key=base64.b64encode(
557+ bytes(private_key)).decode("UTF-8"))
558+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
559+ with person_logged_in(self.person):
560+ self.assertThat(snap.store_secrets, MatchesDict({
561+ "root": Equals("dummy-root"),
562+ "discharge_encrypted": AfterPreprocessing(
563+ container.decrypt, Equals("dummy-discharge")),
564+ }))
565+
566 def makeBuildableDistroArchSeries(self, **kwargs):
567 das = self.factory.makeDistroArchSeries(**kwargs)
568 fake_chroot = self.factory.makeLibraryFileAlias(
569
570=== modified file 'lib/lp/snappy/tests/test_snapstoreclient.py'
571--- lib/lp/snappy/tests/test_snapstoreclient.py 2019-07-29 10:52:35 +0000
572+++ lib/lp/snappy/tests/test_snapstoreclient.py 2019-08-21 09:32:48 +0000
573@@ -14,6 +14,7 @@
574 import json
575
576 from lazr.restful.utils import get_current_browser_request
577+from nacl.public import PrivateKey
578 from pymacaroons import (
579 Macaroon,
580 Verifier,
581@@ -34,9 +35,11 @@
582 )
583 import transaction
584 from zope.component import getUtility
585+from zope.security.proxy import removeSecurityProxy
586
587 from lp.buildmaster.enums import BuildStatus
588 from lp.services.config import config
589+from lp.services.crypto.interfaces import IEncryptedContainer
590 from lp.services.features.testing import FeatureFixture
591 from lp.services.log.logger import BufferLogger
592 from lp.services.memcache.interfaces import IMemcacheClient
593@@ -229,7 +232,7 @@
594 ]
595 self.channels_memcache_key = "search.example:channels".encode("UTF-8")
596
597- def _make_store_secrets(self):
598+ def _make_store_secrets(self, encrypted=False):
599 self.root_key = hashlib.sha256(
600 self.factory.getUniqueString()).hexdigest()
601 root_macaroon = Macaroon(key=self.root_key)
602@@ -241,10 +244,15 @@
603 unbound_discharge_macaroon = Macaroon(
604 location="sso.example", key=self.discharge_key,
605 identifier=self.discharge_caveat_id)
606- return {
607- "root": root_macaroon.serialize(),
608- "discharge": unbound_discharge_macaroon.serialize(),
609- }
610+ secrets = {"root": root_macaroon.serialize()}
611+ if encrypted:
612+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
613+ secrets["discharge_encrypted"] = (
614+ removeSecurityProxy(container.encrypt(
615+ unbound_discharge_macaroon.serialize().encode("UTF-8"))))
616+ else:
617+ secrets["discharge"] = unbound_discharge_macaroon.serialize()
618+ return secrets
619
620 def _addUnscannedUploadResponse(self):
621 responses.add(
622@@ -334,9 +342,9 @@
623 self.client.requestPackageUploadPermission,
624 snappy_series, "test-snap")
625
626- def makeUploadableSnapBuild(self, store_secrets=None):
627+ def makeUploadableSnapBuild(self, store_secrets=None, encrypted=False):
628 if store_secrets is None:
629- store_secrets = self._make_store_secrets()
630+ store_secrets = self._make_store_secrets(encrypted=encrypted)
631 snap = self.factory.makeSnap(
632 store_upload=True,
633 store_series=self.factory.makeSnappySeries(name="rolling"),
634@@ -458,6 +466,46 @@
635 ]))
636
637 @responses.activate
638+ def test_upload_encrypted_discharge(self):
639+ private_key = PrivateKey.generate()
640+ self.pushConfig(
641+ "snappy",
642+ store_secrets_public_key=base64.b64encode(
643+ bytes(private_key.public_key)).decode("UTF-8"),
644+ store_secrets_private_key=base64.b64encode(
645+ bytes(private_key)).decode("UTF-8"))
646+ snapbuild = self.makeUploadableSnapBuild(encrypted=True)
647+ transaction.commit()
648+ self._addUnscannedUploadResponse()
649+ self._addSnapPushResponse()
650+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
651+ self.assertEqual(
652+ "http://sca.example/dev/api/snaps/1/builds/1/status",
653+ self.client.upload(snapbuild))
654+ requests = [call.request for call in responses.calls]
655+ self.assertThat(requests, MatchesListwise([
656+ RequestMatches(
657+ url=Equals("http://updown.example/unscanned-upload/"),
658+ method=Equals("POST"),
659+ form_data={
660+ "binary": MatchesStructure.byEquality(
661+ name="binary", filename="test-snap.snap",
662+ value="dummy snap content",
663+ type="application/octet-stream",
664+ )}),
665+ RequestMatches(
666+ url=Equals("http://sca.example/dev/api/snap-push/"),
667+ method=Equals("POST"),
668+ headers=ContainsDict(
669+ {"Content-Type": Equals("application/json")}),
670+ auth=("Macaroon", MacaroonsVerify(self.root_key)),
671+ json_data={
672+ "name": "test-snap", "updown_id": 1, "series": "rolling",
673+ "built_at": snapbuild.date_started.isoformat(),
674+ }),
675+ ]))
676+
677+ @responses.activate
678 def test_upload_unauthorized(self):
679 store_secrets = self._make_store_secrets()
680 snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
681@@ -504,6 +552,39 @@
682 snapbuild.snap.store_secrets["discharge"])
683
684 @responses.activate
685+ def test_upload_needs_encrypted_discharge_macaroon_refresh(self):
686+ private_key = PrivateKey.generate()
687+ self.pushConfig(
688+ "snappy",
689+ store_secrets_public_key=base64.b64encode(
690+ bytes(private_key.public_key)).decode("UTF-8"),
691+ store_secrets_private_key=base64.b64encode(
692+ bytes(private_key)).decode("UTF-8"))
693+ store_secrets = self._make_store_secrets(encrypted=True)
694+ snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
695+ transaction.commit()
696+ self._addUnscannedUploadResponse()
697+ responses.add(
698+ "POST", "http://sca.example/dev/api/snap-push/", status=401,
699+ headers={"WWW-Authenticate": "Macaroon needs_refresh=1"})
700+ self._addMacaroonRefreshResponse()
701+ self._addSnapPushResponse()
702+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
703+ self.assertEqual(
704+ "http://sca.example/dev/api/snaps/1/builds/1/status",
705+ self.client.upload(snapbuild))
706+ requests = [call.request for call in responses.calls]
707+ self.assertThat(requests, MatchesListwise([
708+ MatchesStructure.byEquality(path_url="/unscanned-upload/"),
709+ MatchesStructure.byEquality(path_url="/dev/api/snap-push/"),
710+ MatchesStructure.byEquality(path_url="/api/v2/tokens/refresh"),
711+ MatchesStructure.byEquality(path_url="/dev/api/snap-push/"),
712+ ]))
713+ self.assertNotEqual(
714+ store_secrets["discharge_encrypted"],
715+ snapbuild.snap.store_secrets["discharge_encrypted"])
716+
717+ @responses.activate
718 def test_upload_unsigned_agreement(self):
719 store_secrets = self._make_store_secrets()
720 snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
721@@ -553,6 +634,36 @@
722 store_secrets["discharge"], snap.store_secrets["discharge"])
723
724 @responses.activate
725+ def test_refresh_encrypted_discharge_macaroon(self):
726+ private_key = PrivateKey.generate()
727+ self.pushConfig(
728+ "snappy",
729+ store_secrets_public_key=base64.b64encode(
730+ bytes(private_key.public_key)).decode("UTF-8"),
731+ store_secrets_private_key=base64.b64encode(
732+ bytes(private_key)).decode("UTF-8"))
733+ store_secrets = self._make_store_secrets(encrypted=True)
734+ snap = self.factory.makeSnap(
735+ store_upload=True,
736+ store_series=self.factory.makeSnappySeries(name="rolling"),
737+ store_name="test-snap", store_secrets=store_secrets)
738+ self._addMacaroonRefreshResponse()
739+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
740+ self.client.refreshDischargeMacaroon(snap)
741+ container = getUtility(IEncryptedContainer, "snap-store-secrets")
742+ self.assertThat(responses.calls[-1].request, RequestMatches(
743+ url=Equals("http://sso.example/api/v2/tokens/refresh"),
744+ method=Equals("POST"),
745+ headers=ContainsDict({"Content-Type": Equals("application/json")}),
746+ json_data={
747+ "discharge_macaroon": container.decrypt(
748+ store_secrets["discharge_encrypted"]).decode("UTF-8"),
749+ }))
750+ self.assertNotEqual(
751+ store_secrets["discharge_encrypted"],
752+ snap.store_secrets["discharge_encrypted"])
753+
754+ @responses.activate
755 def test_checkStatus_pending(self):
756 status_url = "http://sca.example/dev/api/snaps/1/builds/1/status"
757 responses.add(