← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-store-secrets-encrypt into lp:launchpad.

Commit message:
Support encrypting snap store discharge macaroons at rest.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-store-secrets-encrypt/+merge/369428

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-store-secrets-encrypt into lp:launchpad.
=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2019-05-28 13:55:14 +0000
+++ lib/lp/services/config/schema-lazr.conf	2019-06-27 21:46:50 +0000
@@ -1884,6 +1884,16 @@
 # The store's search URL endpoint.
 store_search_url: none
 
+# Base64-encoded NaCl private key for decrypting store upload tokens.
+# This should only be set in secret overlays on systems that need to perform
+# store uploads on behalf of users.
+# datatype: string
+store_secrets_private_key: none
+
+# Base64-encoded NaCl public key for encrypting store upload tokens.
+# datatype: string
+store_secrets_public_key: none
+
 [process-job-source-groups]
 # This section is used by cronscripts/process-job-source-groups.py.
 dbuser: process-job-source-groups

=== added directory 'lib/lp/services/crypto'
=== added file 'lib/lp/services/crypto/__init__.py'
=== added file 'lib/lp/services/crypto/interfaces.py'
--- lib/lp/services/crypto/interfaces.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/crypto/interfaces.py	2019-06-27 21:46:50 +0000
@@ -0,0 +1,56 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface to data encrypted at rest using configured keys."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'CryptoError',
+    'IEncryptedContainer',
+    ]
+
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+
+
+class CryptoError(Exception):
+    pass
+
+
+class IEncryptedContainer(Interface):
+    """Interface to a container that can encrypt and decrypt data."""
+
+    can_encrypt = Attribute(
+        "True iff this container has the configuration it needs to encrypt "
+        "data.")
+
+    def encrypt(data):
+        """Encrypt a blob of data to a JSON-serialisable form.
+
+        This includes the public key to ease future key rotation.
+
+        :param data: An unencrypted byte string to encrypt.
+        :return: A tuple of (base64-encoded public key, base64-encoded
+            encrypted text string).
+        :raises RuntimeError: if no public key is configured for this
+            container.
+        """
+
+    can_decrypt = Attribute(
+        "True iff this container has the configuration it needs to decrypt "
+        "data.")
+
+    def decrypt(data):
+        """Decrypt data that was encrypted by L{encrypt}.
+
+        :param data: A tuple of (base64-encoded public key, base64-encoded
+            encrypted text string) to decrypt.
+        :return: An unencrypted byte string.
+        :raises ValueError: if no private key is configured for this container
+            that corresponds to the requested public key.
+        :raises CryptoError: if decryption failed.
+        """

=== added file 'lib/lp/services/crypto/model.py'
--- lib/lp/services/crypto/model.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/crypto/model.py	2019-06-27 21:46:50 +0000
@@ -0,0 +1,122 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A container for data encrypted at rest using configured keys."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'NaClEncryptedContainerBase',
+    ]
+
+import base64
+
+from nacl.exceptions import CryptoError as NaClCryptoError
+from nacl.public import (
+    PrivateKey,
+    PublicKey,
+    SealedBox,
+    )
+import six
+from zope.interface import implementer
+
+from lp.services.crypto.interfaces import (
+    CryptoError,
+    IEncryptedContainer,
+    )
+
+
+def _make_public_key(public_key_bytes):
+    pass
+
+
+@implementer(IEncryptedContainer)
+class NaClEncryptedContainerBase:
+    """A container that can encrypt and decrypt data using NaCl.
+
+    See `IEncryptedContainer`.
+    """
+
+    @property
+    def public_key_bytes(self):
+        """The serialised public key as a byte string.
+
+        Concrete implementations must provide this.
+        """
+        raise NotImplementedError
+
+    @property
+    def public_key(self):
+        """The public key as a L{nacl.public.PublicKey}."""
+        if self.public_key_bytes is not None:
+            try:
+                return PublicKey(self.public_key_bytes)
+            except NaClCryptoError as e:
+                six.raise_from(CryptoError(str(e)), e)
+        else:
+            return None
+
+    @property
+    def can_encrypt(self):
+        try:
+            return self.public_key is not None
+        except CryptoError:
+            return False
+
+    def encrypt(self, data):
+        """See `IEncryptedContainer`."""
+        if self.public_key is None:
+            raise RuntimeError("No public key configured")
+        try:
+            data_encrypted = SealedBox(self.public_key).encrypt(data)
+        except NaClCryptoError as e:
+            six.raise_from(CryptoError(str(e)), e)
+        return (
+            base64.b64encode(self.public_key_bytes).decode("UTF-8"),
+            base64.b64encode(data_encrypted).decode("UTF-8"))
+
+    @property
+    def private_key_bytes(self):
+        """The serialised private key as a byte string.
+
+        Concrete implementations must provide this.
+        """
+        raise NotImplementedError
+
+    @property
+    def private_key(self):
+        """The private key as a L{nacl.public.PrivateKey}."""
+        if self.private_key_bytes is not None:
+            try:
+                return PrivateKey(self.private_key_bytes)
+            except NaClCryptoError as e:
+                six.raise_from(CryptoError(str(e)), e)
+        else:
+            return None
+
+    @property
+    def can_decrypt(self):
+        try:
+            return self.private_key is not None
+        except CryptoError:
+            return False
+
+    def decrypt(self, data):
+        """See `IEncryptedContainer`."""
+        public_key, encrypted = data
+        try:
+            public_key_bytes = base64.b64decode(public_key.encode("UTF-8"))
+            encrypted_bytes = base64.b64decode(encrypted.encode("UTF-8"))
+        except TypeError as e:
+            six.raise_from(CryptoError(str(e)), e)
+        if public_key_bytes != self.public_key_bytes:
+            raise ValueError(
+                "Public key %r does not match configured public key %r" %
+                (public_key_bytes, self.public_key_bytes))
+        if self.private_key is None:
+            raise ValueError("No private key configured")
+        try:
+            return SealedBox(self.private_key).decrypt(encrypted_bytes)
+        except NaClCryptoError as e:
+            six.raise_from(CryptoError(str(e)), e)

=== added directory 'lib/lp/services/crypto/tests'
=== added file 'lib/lp/services/crypto/tests/__init__.py'
=== added file 'lib/lp/services/crypto/tests/test_model.py'
--- lib/lp/services/crypto/tests/test_model.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/crypto/tests/test_model.py	2019-06-27 21:46:50 +0000
@@ -0,0 +1,83 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for encrypted data containers."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from nacl.public import PrivateKey
+
+from lp.services.crypto.interfaces import CryptoError
+from lp.services.crypto.model import NaClEncryptedContainerBase
+from lp.testing import TestCase
+from lp.testing.layers import ZopelessLayer
+
+
+class FakeEncryptedContainer(NaClEncryptedContainerBase):
+
+    def __init__(self, public_key_bytes, private_key_bytes=None):
+        self._public_key_bytes = public_key_bytes
+        self._private_key_bytes = private_key_bytes
+
+    @property
+    def public_key_bytes(self):
+        return self._public_key_bytes
+
+    @property
+    def private_key_bytes(self):
+        return self._private_key_bytes
+
+
+class TestNaClEncryptedContainerBase(TestCase):
+
+    layer = ZopelessLayer
+
+    def test_public_key_valid(self):
+        public_key = PrivateKey.generate().public_key
+        container = FakeEncryptedContainer(bytes(public_key))
+        self.assertEqual(public_key, container.public_key)
+        self.assertTrue(container.can_encrypt)
+
+    def test_public_key_invalid(self):
+        container = FakeEncryptedContainer(b"nonsense")
+        self.assertRaises(CryptoError, getattr, container, "public_key")
+        self.assertFalse(container.can_encrypt)
+
+    def test_public_key_unset(self):
+        container = FakeEncryptedContainer(None)
+        self.assertIsNone(container.public_key)
+        self.assertFalse(container.can_encrypt)
+
+    def test_encrypt_without_private_key(self):
+        # Encryption only requires the public key, not the private key.
+        public_key = PrivateKey.generate().public_key
+        container = FakeEncryptedContainer(bytes(public_key))
+        self.assertIsNotNone(container.encrypt(b"plaintext"))
+
+    def test_private_key_valid(self):
+        private_key = PrivateKey.generate()
+        container = FakeEncryptedContainer(
+            bytes(private_key.public_key), bytes(private_key))
+        self.assertEqual(private_key, container.private_key)
+        self.assertTrue(container.can_decrypt)
+
+    def test_private_key_invalid(self):
+        public_key = PrivateKey.generate().public_key
+        container = FakeEncryptedContainer(bytes(public_key), b"nonsense")
+        self.assertRaises(CryptoError, getattr, container, "private_key")
+        self.assertFalse(container.can_decrypt)
+
+    def test_private_key_unset(self):
+        public_key = PrivateKey.generate().public_key
+        container = FakeEncryptedContainer(bytes(public_key), None)
+        self.assertIsNone(container.private_key)
+        self.assertFalse(container.can_decrypt)
+
+    def test_encrypt_decrypt(self):
+        private_key = PrivateKey.generate()
+        container = FakeEncryptedContainer(
+            bytes(private_key.public_key), bytes(private_key))
+        self.assertEqual(
+            b"plaintext", container.decrypt(container.encrypt(b"plaintext")))

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2019-03-12 19:18:19 +0000
+++ lib/lp/snappy/configure.zcml	2019-06-27 21:46:50 +0000
@@ -49,6 +49,14 @@
             interface="lp.snappy.interfaces.snap.ISnapBuildRequest" />
     </class>
 
+    <!-- SnapStoreSecretsEncryptedContainer -->
+    <securedutility
+        class="lp.snappy.model.snap.SnapStoreSecretsEncryptedContainer"
+        provides="lp.services.crypto.interfaces.IEncryptedContainer"
+        name="snap-store-secrets">
+        <allow interface="lp.services.crypto.interfaces.IEncryptedContainer"/>
+    </securedutility>
+
     <!-- SnapBuild -->
     <class class="lp.snappy.model.snapbuild.SnapBuild">
         <require

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2019-05-16 10:21:14 +0000
+++ lib/lp/snappy/model/snap.py	2019-06-27 21:46:50 +0000
@@ -6,6 +6,7 @@
     'Snap',
     ]
 
+import base64
 from collections import OrderedDict
 from datetime import (
     datetime,
@@ -97,6 +98,8 @@
 from lp.registry.model.series import ACTIVE_STATUSES
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
+from lp.services.crypto.model import NaClEncryptedContainerBase
 from lp.services.database.bulk import load_related
 from lp.services.database.constants import (
     DEFAULT,
@@ -594,9 +597,18 @@
                     "beginAuthorization must be called before "
                     "completeAuthorization.")
         if discharge_macaroon is not None:
-            self.store_secrets["discharge"] = discharge_macaroon
+            container = getUtility(IEncryptedContainer, "snap-store-secrets")
+            if container.can_encrypt:
+                self.store_secrets["discharge_encrypted"] = (
+                    removeSecurityProxy(container.encrypt(
+                        discharge_macaroon.encode("UTF-8"))))
+                self.store_secrets.pop("discharge", None)
+            else:
+                self.store_secrets["discharge"] = discharge_macaroon
+                self.store_secrets.pop("discharge_encrypted", None)
         else:
             self.store_secrets.pop("discharge", None)
+            self.store_secrets.pop("discharge_encrypted", None)
 
     @property
     def can_upload_to_store(self):
@@ -609,7 +621,8 @@
             return False
         root_macaroon = Macaroon.deserialize(self.store_secrets["root"])
         if (self.extractSSOCaveats(root_macaroon) and
-                "discharge" not in self.store_secrets):
+                "discharge" not in self.store_secrets and
+                "discharge_encrypted" not in self.store_secrets):
             return False
         return True
 
@@ -1380,3 +1393,23 @@
     def empty_list(self):
         """See `ISnapSet`."""
         return []
+
+
+@implementer(IEncryptedContainer)
+class SnapStoreSecretsEncryptedContainer(NaClEncryptedContainerBase):
+
+    @property
+    def public_key_bytes(self):
+        if config.snappy.store_secrets_public_key is not None:
+            return base64.b64decode(
+                config.snappy.store_secrets_public_key.encode("UTF-8"))
+        else:
+            return None
+
+    @property
+    def private_key_bytes(self):
+        if config.snappy.store_secrets_private_key is not None:
+            return base64.b64decode(
+                config.snappy.store_secrets_private_key.encode("UTF-8"))
+        else:
+            return None

=== modified file 'lib/lp/snappy/model/snapstoreclient.py'
--- lib/lp/snappy/model/snapstoreclient.py	2019-06-20 16:33:19 +0000
+++ lib/lp/snappy/model/snapstoreclient.py	2019-06-27 21:46:50 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Communication with the snap store."""
@@ -30,6 +30,10 @@
 from zope.security.proxy import removeSecurityProxy
 
 from lp.services.config import config
+from lp.services.crypto.interfaces import (
+    CryptoError,
+    IEncryptedContainer,
+    )
 from lp.services.features import getFeatureFlag
 from lp.services.memcache.interfaces import IMemcacheClient
 from lp.services.scripts import log
@@ -154,6 +158,26 @@
         return r
 
 
+def _get_discharge_macaroon_raw(snap):
+    """Get the serialised discharge macaroon for a snap, if any.
+
+    This copes with either unencrypted (the historical default) or encrypted
+    macaroons.
+    """
+    if snap.store_secrets is None:
+        raise AssertionError("snap.store_secrets is None")
+    if "discharge_encrypted" in snap.store_secrets:
+        container = getUtility(IEncryptedContainer, "snap-store-secrets")
+        try:
+            return container.decrypt(
+                snap.store_secrets["discharge_encrypted"]).decode("UTF-8")
+        except CryptoError as e:
+            raise UnauthorizedUploadResponse(
+                "Failed to decrypt discharge macaroon: %s" % e)
+    else:
+        return snap.store_secrets.get("discharge")
+
+
 # Hardcoded fallback.
 _default_store_channels = [
     {"name": "candidate", "display_name": "Candidate"},
@@ -257,6 +281,7 @@
         snap = snapbuild.snap
         assert config.snappy.store_url is not None
         assert snap.store_name is not None
+        assert snap.store_secrets is not None
         assert snapbuild.date_started is not None
         upload_url = urlappend(config.snappy.store_url, "dev/api/snap-push/")
         data = {
@@ -276,12 +301,11 @@
         # XXX cjwatson 2016-05-09: This should add timeline information, but
         # that's currently difficult in jobs.
         try:
-            assert snap.store_secrets is not None
             response = urlfetch(
                 upload_url, method="POST", json=data,
                 auth=MacaroonAuth(
                     snap.store_secrets["root"],
-                    snap.store_secrets.get("discharge")))
+                    _get_discharge_macaroon_raw(snap)))
             response_data = response.json()
             return response_data["status_details_url"]
         except requests.HTTPError as e:
@@ -312,7 +336,12 @@
         assert snap.store_secrets is not None
         refresh_url = urlappend(
             config.launchpad.openid_provider_root, "api/v2/tokens/refresh")
-        data = {"discharge_macaroon": snap.store_secrets["discharge"]}
+        discharge_macaroon_raw = _get_discharge_macaroon_raw(snap)
+        if discharge_macaroon_raw is None:
+            raise UnauthorizedUploadResponse(
+                "Tried to refresh discharge for snap with no discharge "
+                "macaroon")
+        data = {"discharge_macaroon": discharge_macaroon_raw}
         try:
             response = urlfetch(refresh_url, method="POST", json=data)
             response_data = response.json()
@@ -320,7 +349,15 @@
                 raise BadRefreshResponse(response.text)
             # Set a new dict here to avoid problems with security proxies.
             new_secrets = dict(snap.store_secrets)
-            new_secrets["discharge"] = response_data["discharge_macaroon"]
+            container = getUtility(IEncryptedContainer, "snap-store-secrets")
+            if container.can_encrypt:
+                new_secrets["discharge_encrypted"] = (
+                    removeSecurityProxy(container.encrypt(
+                        response_data["discharge_macaroon"].encode("UTF-8"))))
+                new_secrets.pop("discharge", None)
+            else:
+                new_secrets["discharge"] = response_data["discharge_macaroon"]
+                new_secrets.pop("discharge_encrypted", None)
             snap.store_secrets = new_secrets
         except requests.HTTPError as e:
             raise cls._makeSnapStoreError(BadRefreshResponse, e)

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2019-06-20 13:16:35 +0000
+++ lib/lp/snappy/tests/test_snap.py	2019-06-27 21:46:50 +0000
@@ -7,6 +7,7 @@
 
 __metaclass__ = type
 
+import base64
 from datetime import (
     datetime,
     timedelta,
@@ -21,6 +22,7 @@
     MockPatch,
     )
 import iso8601
+from nacl.public import PrivateKey
 from pymacaroons import Macaroon
 import pytz
 import responses
@@ -67,6 +69,7 @@
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.database.constants import (
     ONE_DAY_AGO,
     UTC_NOW,
@@ -3100,6 +3103,37 @@
         with person_logged_in(self.person):
             self.assertEqual({"root": "dummy-root"}, snap.store_secrets)
 
+    def test_completeAuthorization_encrypted(self):
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "snappy",
+            store_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode("UTF-8"))
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": "dummy-root"})
+        snap_url = api_url(snap)
+        logout()
+        response = self.webservice.named_post(
+            snap_url, "completeAuthorization",
+            discharge_macaroon="dummy-discharge")
+        self.assertEqual(200, response.status)
+        self.pushConfig(
+            "snappy",
+            store_secrets_private_key=base64.b64encode(
+                bytes(private_key)).decode("UTF-8"))
+        container = getUtility(IEncryptedContainer, "snap-store-secrets")
+        with person_logged_in(self.person):
+            self.assertThat(snap.store_secrets, MatchesDict({
+                "root": Equals("dummy-root"),
+                "discharge_encrypted": AfterPreprocessing(
+                    container.decrypt, Equals("dummy-discharge")),
+                }))
+
     def makeBuildableDistroArchSeries(self, **kwargs):
         das = self.factory.makeDistroArchSeries(**kwargs)
         fake_chroot = self.factory.makeLibraryFileAlias(

=== modified file 'lib/lp/snappy/tests/test_snapstoreclient.py'
--- lib/lp/snappy/tests/test_snapstoreclient.py	2019-06-20 16:33:19 +0000
+++ lib/lp/snappy/tests/test_snapstoreclient.py	2019-06-27 21:46:50 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for communication with the snap store."""
@@ -14,6 +14,7 @@
 import json
 
 from lazr.restful.utils import get_current_browser_request
+from nacl.public import PrivateKey
 from pymacaroons import (
     Macaroon,
     Verifier,
@@ -34,9 +35,11 @@
     )
 import transaction
 from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import BuildStatus
 from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.features.testing import FeatureFixture
 from lp.services.log.logger import BufferLogger
 from lp.services.memcache.interfaces import IMemcacheClient
@@ -229,7 +232,7 @@
             ]
         self.channels_memcache_key = "search.example:channels".encode("UTF-8")
 
-    def _make_store_secrets(self):
+    def _make_store_secrets(self, encrypted=False):
         self.root_key = hashlib.sha256(
             self.factory.getUniqueString()).hexdigest()
         root_macaroon = Macaroon(key=self.root_key)
@@ -241,10 +244,15 @@
         unbound_discharge_macaroon = Macaroon(
             location="sso.example", key=self.discharge_key,
             identifier=self.discharge_caveat_id)
-        return {
-            "root": root_macaroon.serialize(),
-            "discharge": unbound_discharge_macaroon.serialize(),
-            }
+        secrets = {"root": root_macaroon.serialize()}
+        if encrypted:
+            container = getUtility(IEncryptedContainer, "snap-store-secrets")
+            secrets["discharge_encrypted"] = (
+                removeSecurityProxy(container.encrypt(
+                    unbound_discharge_macaroon.serialize().encode("UTF-8"))))
+        else:
+            secrets["discharge"] = unbound_discharge_macaroon.serialize()
+        return secrets
 
     def _addUnscannedUploadResponse(self):
         responses.add(
@@ -334,9 +342,9 @@
             self.client.requestPackageUploadPermission,
             snappy_series, "test-snap")
 
-    def makeUploadableSnapBuild(self, store_secrets=None):
+    def makeUploadableSnapBuild(self, store_secrets=None, encrypted=False):
         if store_secrets is None:
-            store_secrets = self._make_store_secrets()
+            store_secrets = self._make_store_secrets(encrypted=encrypted)
         snap = self.factory.makeSnap(
             store_upload=True,
             store_series=self.factory.makeSnappySeries(name="rolling"),
@@ -458,6 +466,46 @@
             ]))
 
     @responses.activate
+    def test_upload_encrypted_discharge(self):
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "snappy",
+            store_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode("UTF-8"),
+            store_secrets_private_key=base64.b64encode(
+                bytes(private_key)).decode("UTF-8"))
+        snapbuild = self.makeUploadableSnapBuild(encrypted=True)
+        transaction.commit()
+        self._addUnscannedUploadResponse()
+        self._addSnapPushResponse()
+        with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+            self.assertEqual(
+                "http://sca.example/dev/api/snaps/1/builds/1/status";,
+                self.client.upload(snapbuild))
+        requests = [call.request for call in responses.calls]
+        self.assertThat(requests, MatchesListwise([
+            RequestMatches(
+                url=Equals("http://updown.example/unscanned-upload/";),
+                method=Equals("POST"),
+                form_data={
+                    "binary": MatchesStructure.byEquality(
+                        name="binary", filename="test-snap.snap",
+                        value="dummy snap content",
+                        type="application/octet-stream",
+                        )}),
+            RequestMatches(
+                url=Equals("http://sca.example/dev/api/snap-push/";),
+                method=Equals("POST"),
+                headers=ContainsDict(
+                    {"Content-Type": Equals("application/json")}),
+                auth=("Macaroon", MacaroonsVerify(self.root_key)),
+                json_data={
+                    "name": "test-snap", "updown_id": 1, "series": "rolling",
+                    "built_at": snapbuild.date_started.isoformat(),
+                    }),
+            ]))
+
+    @responses.activate
     def test_upload_unauthorized(self):
         store_secrets = self._make_store_secrets()
         snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
@@ -504,6 +552,39 @@
             snapbuild.snap.store_secrets["discharge"])
 
     @responses.activate
+    def test_upload_needs_encrypted_discharge_macaroon_refresh(self):
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "snappy",
+            store_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode("UTF-8"),
+            store_secrets_private_key=base64.b64encode(
+                bytes(private_key)).decode("UTF-8"))
+        store_secrets = self._make_store_secrets(encrypted=True)
+        snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
+        transaction.commit()
+        self._addUnscannedUploadResponse()
+        responses.add(
+            "POST", "http://sca.example/dev/api/snap-push/";, status=401,
+            headers={"WWW-Authenticate": "Macaroon needs_refresh=1"})
+        self._addMacaroonRefreshResponse()
+        self._addSnapPushResponse()
+        with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+            self.assertEqual(
+                "http://sca.example/dev/api/snaps/1/builds/1/status";,
+                self.client.upload(snapbuild))
+        requests = [call.request for call in responses.calls]
+        self.assertThat(requests, MatchesListwise([
+            MatchesStructure.byEquality(path_url="/unscanned-upload/"),
+            MatchesStructure.byEquality(path_url="/dev/api/snap-push/"),
+            MatchesStructure.byEquality(path_url="/api/v2/tokens/refresh"),
+            MatchesStructure.byEquality(path_url="/dev/api/snap-push/"),
+            ]))
+        self.assertNotEqual(
+            store_secrets["discharge_encrypted"],
+            snapbuild.snap.store_secrets["discharge_encrypted"])
+
+    @responses.activate
     def test_upload_unsigned_agreement(self):
         store_secrets = self._make_store_secrets()
         snapbuild = self.makeUploadableSnapBuild(store_secrets=store_secrets)
@@ -553,6 +634,36 @@
             store_secrets["discharge"], snap.store_secrets["discharge"])
 
     @responses.activate
+    def test_refresh_encrypted_discharge_macaroon(self):
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "snappy",
+            store_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode("UTF-8"),
+            store_secrets_private_key=base64.b64encode(
+                bytes(private_key)).decode("UTF-8"))
+        store_secrets = self._make_store_secrets(encrypted=True)
+        snap = self.factory.makeSnap(
+            store_upload=True,
+            store_series=self.factory.makeSnappySeries(name="rolling"),
+            store_name="test-snap", store_secrets=store_secrets)
+        self._addMacaroonRefreshResponse()
+        with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+            self.client.refreshDischargeMacaroon(snap)
+        container = getUtility(IEncryptedContainer, "snap-store-secrets")
+        self.assertThat(responses.calls[-1].request, RequestMatches(
+            url=Equals("http://sso.example/api/v2/tokens/refresh";),
+            method=Equals("POST"),
+            headers=ContainsDict({"Content-Type": Equals("application/json")}),
+            json_data={
+                "discharge_macaroon": container.decrypt(
+                    store_secrets["discharge_encrypted"]).decode("UTF-8"),
+                }))
+        self.assertNotEqual(
+            store_secrets["discharge_encrypted"],
+            snap.store_secrets["discharge_encrypted"])
+
+    @responses.activate
     def test_checkStatus_pending(self):
         status_url = "http://sca.example/dev/api/snaps/1/builds/1/status";
         responses.add(


Follow ups