← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charmhub-authorization into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-authorization into launchpad:master.

Commit message:
Add mechanisms and a view for authorizing Charmhub uploads

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/406852

As with snaps, we also automatically direct the user through this workflow if they configure a charm recipe such that it requires authorization.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-authorization into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index 501f700..8bbaba6 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,6 +7,7 @@ __metaclass__ = type
 __all__ = [
     "CharmRecipeAddView",
     "CharmRecipeAdminView",
+    "CharmRecipeAuthorizeView",
     "CharmRecipeContextMenu",
     "CharmRecipeDeleteView",
     "CharmRecipeEditView",
@@ -22,6 +23,7 @@ from lazr.restful.interface import (
     use_template,
     )
 from zope.component import getUtility
+from zope.error.interfaces import IErrorReportingUtility
 from zope.interface import (
     implementer,
     Interface,
@@ -43,7 +45,11 @@ from lp.app.browser.tales import format_link
 from lp.charms.browser.widgets.charmrecipebuildchannels import (
     CharmRecipeBuildChannelsWidget,
     )
+from lp.charms.interfaces.charmhubclient import (
+    BadRequestPackageUploadResponse,
+    )
 from lp.charms.interfaces.charmrecipe import (
+    CannotAuthorizeCharmhubUploads,
     ICharmRecipe,
     ICharmRecipeSet,
     NoSuchCharmRecipe,
@@ -64,11 +70,13 @@ from lp.services.webapp import (
     Navigation,
     NavigationMenu,
     stepthrough,
+    structured,
     )
 from lp.services.webapp.breadcrumb import (
     Breadcrumb,
     NameBreadcrumb,
     )
+from lp.services.webapp.candid import request_candid_discharge
 from lp.services.webapp.interfaces import ICanonicalUrlData
 from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
 from lp.soyuz.browser.build import get_build_by_id_str
@@ -130,7 +138,7 @@ class CharmRecipeNavigationMenu(NavigationMenu):
 
     facet = "overview"
 
-    links = ("admin", "edit", "delete")
+    links = ("admin", "edit", "authorize", "delete")
 
     @enabled_with_permission("launchpad.Admin")
     def admin(self):
@@ -141,6 +149,14 @@ class CharmRecipeNavigationMenu(NavigationMenu):
         return Link("+edit", "Edit charm recipe", icon="edit")
 
     @enabled_with_permission("launchpad.Edit")
+    def authorize(self):
+        if self.context.store_secrets:
+            text = "Reauthorize Charmhub uploads"
+        else:
+            text = "Authorize Charmhub uploads"
+        return Link("+authorize", text, icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
     def delete(self):
         return Link("+delete", "Delete charm recipe", icon="trash-icon")
 
@@ -264,7 +280,26 @@ class ICharmRecipeEditSchema(Interface):
     store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
 
 
-class CharmRecipeAddView(LaunchpadFormView):
+def log_oops(error, request):
+    """Log an oops report without raising an error."""
+    info = (error.__class__, error, None)
+    getUtility(IErrorReportingUtility).raising(info, request)
+
+
+class CharmRecipeAuthorizeMixin:
+
+    def requestAuthorization(self, recipe):
+        try:
+            self.next_url = CharmRecipeAuthorizeView.requestAuthorization(
+                recipe, self.request)
+        except BadRequestPackageUploadResponse as e:
+            self.setFieldError(
+                "store_upload",
+                "Cannot get permission from Charmhub to upload this package.")
+            log_oops(e, self.request)
+
+
+class CharmRecipeAddView(CharmRecipeAuthorizeMixin, LaunchpadFormView):
     """View for creating charm recipes."""
 
     page_title = label = "Create a new charm recipe"
@@ -336,7 +371,10 @@ class CharmRecipeAddView(LaunchpadFormView):
             store_upload=data["store_upload"],
             store_name=data["store_name"],
             store_channels=data.get("store_channels"))
-        self.next_url = canonical_url(recipe)
+        if data["store_upload"]:
+            self.requestAuthorization(recipe)
+        else:
+            self.next_url = canonical_url(recipe)
 
     def validate(self, data):
         super(CharmRecipeAddView, self).validate(data)
@@ -354,7 +392,8 @@ class CharmRecipeAddView(LaunchpadFormView):
                     "this name." % (owner.display_name, project.display_name))
 
 
-class BaseCharmRecipeEditView(LaunchpadEditFormView):
+class BaseCharmRecipeEditView(
+        CharmRecipeAuthorizeMixin, LaunchpadEditFormView):
 
     schema = ICharmRecipeEditSchema
 
@@ -391,6 +430,16 @@ class BaseCharmRecipeEditView(LaunchpadEditFormView):
                     "git_ref",
                     "A public charm recipe cannot have a private repository.")
 
+    def _needCharmhubReauth(self, data):
+        """Does this change require reauthorizing to Charmhub?"""
+        store_upload = data.get("store_upload", False)
+        store_name = data.get("store_name")
+        if not store_upload or store_name is None:
+            return False
+        return (
+            not self.context.store_upload or
+            store_name != self.context.store_name)
+
     @action("Update charm recipe", name="update")
     def request_action(self, action, data):
         if not data.get("auto_build", False):
@@ -402,8 +451,12 @@ class BaseCharmRecipeEditView(LaunchpadEditFormView):
                 del data["store_name"]
             if "store_channels" in data:
                 del data["store_channels"]
+        need_charmhub_reauth = self._needCharmhubReauth(data)
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        if need_charmhub_reauth:
+            self.requestAuthorization(self.context)
+        else:
+            self.next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -466,6 +519,69 @@ class CharmRecipeEditView(BaseCharmRecipeEditView):
                 pass
 
 
+class CharmRecipeAuthorizeView(LaunchpadEditFormView):
+    """View for authorizing charm recipe uploads to Charmhub."""
+
+    @property
+    def label(self):
+        return "Authorize Charmhub uploads of %s" % self.context.name
+
+    page_title = "Authorize Charmhub uploads"
+
+    class schema(Interface):
+        """Schema for authorizing charm recipe uploads to Charmhub."""
+
+        discharge_macaroon = TextLine(
+            title="Serialized discharge macaroon", required=True)
+
+    render_context = False
+
+    focusedElementScript = None
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @classmethod
+    def requestAuthorization(cls, recipe, request):
+        """Begin the process of authorizing uploads of a charm recipe."""
+        try:
+            root_macaroon_raw = recipe.beginAuthorization()
+        except CannotAuthorizeCharmhubUploads as e:
+            request.response.addInfoNotification(str(e))
+            request.response.redirect(canonical_url(recipe))
+        else:
+            base_url = canonical_url(recipe, view_name="+authorize")
+            return request_candid_discharge(
+                request, root_macaroon_raw, base_url,
+                "field.discharge_macaroon",
+                discharge_macaroon_action="field.actions.complete")
+
+    @action("Begin authorization", name="begin")
+    def begin_action(self, action, data):
+        login_url = self.requestAuthorization(self.context, self.request)
+        if login_url is not None:
+            self.request.response.redirect(login_url)
+
+    @action("Complete authorization", name="complete")
+    def complete_action(self, action, data):
+        if not data.get("discharge_macaroon"):
+            self.addError(structured(
+                _("Uploads of %(recipe)s to Charmhub were not authorized."),
+                recipe=self.context.name))
+            return
+        self.context.completeAuthorization(data["discharge_macaroon"])
+        self.request.response.addInfoNotification(structured(
+            _("Uploads of %(recipe)s to Charmhub are now authorized."),
+            recipe=self.context.name))
+        self.request.response.redirect(canonical_url(self.context))
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {self.schema: self.context}
+
+
 class CharmRecipeDeleteView(BaseCharmRecipeEditView):
     """View for deleting charm recipes."""
 
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 868316b..2577f78 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -42,6 +42,12 @@
             template="../templates/charmrecipe-edit.pt" />
         <browser:page
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+            class="lp.charms.browser.charmrecipe.CharmRecipeAuthorizeView"
+            permission="launchpad.Edit"
+            name="+authorize"
+            template="../templates/charmrecipe-authorize.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
             class="lp.charms.browser.charmrecipe.CharmRecipeDeleteView"
             permission="launchpad.Edit"
             name="+delete"
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index 6dc4a2d..4e72f78 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -5,19 +5,31 @@
 
 __metaclass__ = type
 
+import base64
 from datetime import (
     datetime,
     timedelta,
     )
+import json
 import re
+from urllib.parse import (
+    parse_qs,
+    urlsplit,
+    )
 
 from fixtures import FakeLogger
+from nacl.public import PrivateKey
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
 import pytz
+import responses
 import soupmatchers
 from testtools.matchers import (
     AfterPreprocessing,
+    ContainsDict,
     Equals,
     Is,
+    MatchesDict,
     MatchesListwise,
     MatchesStructure,
     )
@@ -40,14 +52,17 @@ from lp.charms.browser.charmrecipe import (
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CharmRecipeBuildRequestStatus,
+    ICharmRecipeSet,
     )
 from lp.registry.enums import PersonVisibility
+from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.propertycache import get_property_cache
 from lp.services.webapp import canonical_url
 from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.testing import (
     BrowserTestCase,
     login,
@@ -57,6 +72,8 @@ from lp.testing import (
     TestCaseWithFactory,
     time_counter,
     )
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.fixture import ZopeUtilityFixture
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
@@ -70,6 +87,7 @@ from lp.testing.pages import (
     find_main_content,
     find_tag_by_id,
     find_tags_by_class,
+    get_feedback_messages,
     )
 from lp.testing.publication import test_traverse
 from lp.testing.views import (
@@ -111,6 +129,13 @@ class BaseTestCharmRecipeView(BrowserTestCase):
         super(BaseTestCharmRecipeView, self).setUp()
         self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
         self.useFixture(FakeLogger())
+        self.snap_store_client = FakeMethod()
+        self.snap_store_client.listChannels = FakeMethod(result=[
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ])
+        self.useFixture(
+            ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
         self.person = self.factory.makePerson(
             name="test-person", displayname="Test Person")
 
@@ -263,6 +288,115 @@ class TestCharmRecipeAddView(BaseTestCharmRecipeView):
             "core20\nedge/feature\n",
             MatchesTagText(content, "auto_build_channels"))
 
+    @responses.activate
+    def test_create_new_recipe_store_upload(self):
+        # Creating a new recipe and asking for it to be automatically
+        # uploaded to Charmhub sets all the appropriate fields and redirects
+        # to Candid for authorization.
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        project = self.factory.makeProduct(
+            name="test-project", displayname="Test Project")
+        [git_ref] = self.factory.makeGitRefs()
+        view_url = canonical_url(git_ref, view_name="+new-charm-recipe")
+        browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
+        browser.getControl(name="field.name").value = "charm-name"
+        browser.getControl(name="field.project").value = "test-project"
+        browser.getControl("Automatically upload to store").selected = True
+        browser.getControl("Registered store name").value = "charmhub-name"
+        self.assertFalse(browser.getControl("Stable").selected)
+        browser.getControl(name="field.store_channels.track").value = "track"
+        browser.getControl("Edge").selected = True
+        root_macaroon = Macaroon(version=2)
+        root_macaroon.add_third_party_caveat(
+            "https://candid.test/";, "", "identity")
+        caveat = root_macaroon.caveats[0]
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens";,
+            json={"macaroon": root_macaroon_raw})
+        responses.add(
+            "POST", "https://candid.test/discharge";, status=401,
+            json={
+                "Code": "interaction required",
+                "Message": (
+                    "macaroon discharge required: authentication required"),
+                "Info": {
+                    "InteractionMethods": {
+                        "browser-redirect": {
+                            "LoginURL": "https://candid.test/login-redirect";,
+                            },
+                        },
+                    },
+                })
+        browser.getControl("Create charm recipe").click()
+        login_person(self.person)
+        recipe = getUtility(ICharmRecipeSet).getByName(
+            self.person, project, "charm-name")
+        self.assertThat(recipe, MatchesStructure.byEquality(
+            owner=self.person, project=project, name="charm-name",
+            source=git_ref, store_upload=True, store_name="charmhub-name",
+            store_secrets={"root": root_macaroon_raw},
+            store_channels=["track/edge"]))
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("http://charmhub.example/v1/tokens";),
+                    method=Equals("POST"),
+                    body=AfterPreprocessing(
+                        lambda b: json.loads(b.decode()),
+                        Equals({
+                            "description": "charmhub-name for launchpad.test",
+                            "packages": [
+                                {"type": "charm", "name": "charmhub-name"},
+                                ],
+                            "permissions": [
+                                "package-manage-releases",
+                                "package-manage-revisions",
+                                ],
+                            })))),
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("https://candid.test/discharge";),
+                    headers=ContainsDict({
+                        "Content-Type": Equals(
+                            "application/x-www-form-urlencoded"),
+                        }),
+                    body=AfterPreprocessing(parse_qs, MatchesDict({
+                        "id64": Equals(
+                            [base64.b64encode(
+                                caveat.caveat_id_bytes).decode()]),
+                        })))),
+            ]))
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        self.assertThat(
+            urlsplit(browser.headers["Location"]),
+            MatchesStructure(
+                scheme=Equals("https"),
+                netloc=Equals("candid.test"),
+                path=Equals("/login-redirect"),
+                query=AfterPreprocessing(parse_qs, ContainsDict({
+                    "return_to": MatchesListwise([
+                        AfterPreprocessing(urlsplit, MatchesStructure(
+                            scheme=Equals("http"),
+                            netloc=Equals("launchpad.test"),
+                            path=Equals("/+candid-callback"),
+                            query=AfterPreprocessing(parse_qs, MatchesDict({
+                                "starting_url": Equals(
+                                    [canonical_url(recipe) + "/+authorize"]),
+                                "discharge_macaroon_action": Equals(
+                                    ["field.actions.complete"]),
+                                "discharge_macaroon_field": Equals(
+                                    ["field.discharge_macaroon"]),
+                                })),
+                            fragment=Equals(""))),
+                        ]),
+                    })),
+                fragment=Equals("")))
+
 
 class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
 
@@ -422,6 +556,209 @@ class TestCharmRecipeEditView(BaseTestCharmRecipeView):
             extract_text(find_tags_by_class(browser.contents, "message")[1]))
 
 
+class TestCharmRecipeAuthorizeView(BaseTestCharmRecipeView):
+
+    def setUp(self):
+        super().setUp()
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.pushConfig(
+            "launchpad",
+            candid_service_root="https://candid.test/";,
+            csrf_secret="test secret")
+        self.recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person, store_upload=True,
+            store_name=self.factory.getUniqueUnicode())
+
+    def test_unauthorized(self):
+        # A user without edit access cannot authorize charm recipe uploads.
+        other_person = self.factory.makePerson()
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser,
+            canonical_url(self.recipe) + "/+authorize", user=other_person)
+
+    @responses.activate
+    def test_begin_authorization(self):
+        # With no special form actions, we return a form inviting the user
+        # to begin authorization.  This allows (re-)authorizing uploads of
+        # an existing charm recipe without having to edit it.
+        recipe_url = canonical_url(self.recipe)
+        owner = self.recipe.owner
+        browser = self.getNonRedirectingBrowser(
+            url=recipe_url + "/+authorize", user=self.recipe.owner)
+        root_macaroon = Macaroon(version=2)
+        root_macaroon.add_third_party_caveat(
+            "https://candid.test/";, "", "identity")
+        caveat = root_macaroon.caveats[0]
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens";,
+            json={"macaroon": root_macaroon_raw})
+        responses.add(
+            "POST", "https://candid.test/discharge";, status=401,
+            json={
+                "Code": "interaction required",
+                "Message": (
+                    "macaroon discharge required: authentication required"),
+                "Info": {
+                    "InteractionMethods": {
+                        "browser-redirect": {
+                            "LoginURL": "https://candid.test/login-redirect";,
+                            },
+                        },
+                    },
+                })
+        browser.getControl("Begin authorization").click()
+        with person_logged_in(owner):
+            self.assertThat(responses.calls, MatchesListwise([
+                MatchesStructure(
+                    request=MatchesStructure(
+                        url=Equals("http://charmhub.example/v1/tokens";),
+                        method=Equals("POST"),
+                        body=AfterPreprocessing(
+                            lambda b: json.loads(b.decode()),
+                            Equals({
+                                "description": (
+                                    "{} for launchpad.test".format(
+                                        self.recipe.store_name)),
+                                "packages": [
+                                    {"type": "charm",
+                                     "name": self.recipe.store_name},
+                                    ],
+                                "permissions": [
+                                    "package-manage-releases",
+                                    "package-manage-revisions",
+                                    ],
+                                })))),
+                MatchesStructure(
+                    request=MatchesStructure(
+                        url=Equals("https://candid.test/discharge";),
+                        headers=ContainsDict({
+                            "Content-Type": Equals(
+                                "application/x-www-form-urlencoded"),
+                            }),
+                        body=AfterPreprocessing(parse_qs, MatchesDict({
+                            "id64": Equals(
+                                [base64.b64encode(
+                                    caveat.caveat_id_bytes).decode()]),
+                            })))),
+                ]))
+            self.assertEqual(
+                {"root": root_macaroon_raw}, self.recipe.store_secrets)
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        self.assertThat(
+            urlsplit(browser.headers["Location"]),
+            MatchesStructure(
+                scheme=Equals("https"),
+                netloc=Equals("candid.test"),
+                path=Equals("/login-redirect"),
+                query=AfterPreprocessing(parse_qs, ContainsDict({
+                    "return_to": MatchesListwise([
+                        AfterPreprocessing(urlsplit, MatchesStructure(
+                            scheme=Equals("http"),
+                            netloc=Equals("launchpad.test"),
+                            path=Equals("/+candid-callback"),
+                            query=AfterPreprocessing(parse_qs, MatchesDict({
+                                "starting_url": Equals(
+                                    [recipe_url + "/+authorize"]),
+                                "discharge_macaroon_action": Equals(
+                                    ["field.actions.complete"]),
+                                "discharge_macaroon_field": Equals(
+                                    ["field.discharge_macaroon"]),
+                                })),
+                            fragment=Equals(""))),
+                        ]),
+                    })),
+                fragment=Equals("")))
+
+    def test_complete_authorization_missing_discharge_macaroon(self):
+        # If the form does not include a discharge macaroon, the "complete"
+        # action fails.
+        with person_logged_in(self.recipe.owner):
+            self.recipe.store_secrets = {
+                "root": Macaroon(version=2).serialize(JsonSerializer()),
+                }
+            transaction.commit()
+            form = {"field.actions.complete": "1"}
+            view = create_initialized_view(
+                self.recipe, "+authorize", form=form, method="POST",
+                principal=self.recipe.owner)
+            html = view()
+            self.assertEqual(
+                "Uploads of %s to Charmhub were not authorized." %
+                self.recipe.name,
+                get_feedback_messages(html)[1])
+            self.assertNotIn("exchanged_encrypted", self.recipe.store_secrets)
+
+    @responses.activate
+    def test_complete_authorization(self):
+        # If the form includes a discharge macaroon, the "complete" action
+        # exchanges the root and discharge pair with Charmhub for a single
+        # macaroon, then succeeds and records the new secrets.
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "charms",
+            charmhub_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode())
+        root_macaroon = Macaroon(version=2)
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        unbound_discharge_macaroon = Macaroon(version=2)
+        unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+            JsonSerializer())
+        discharge_macaroon_raw = root_macaroon.prepare_for_request(
+            unbound_discharge_macaroon).serialize(JsonSerializer())
+        exchanged_macaroon = Macaroon(version=2)
+        exchanged_macaroon_raw = exchanged_macaroon.serialize(JsonSerializer())
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";,
+            json={"macaroon": exchanged_macaroon_raw})
+        with person_logged_in(self.recipe.owner):
+            self.recipe.store_secrets = {"root": root_macaroon_raw}
+            transaction.commit()
+            form = {
+                "field.actions.complete": "1",
+                "field.discharge_macaroon": unbound_discharge_macaroon_raw,
+                }
+            view = create_initialized_view(
+                self.recipe, "+authorize", form=form, method="POST",
+                principal=self.recipe.owner)
+            self.assertEqual(302, view.request.response.getStatus())
+            self.assertEqual(
+                canonical_url(self.recipe),
+                view.request.response.getHeader("Location"))
+            self.assertEqual(
+                "Uploads of %s to Charmhub are now authorized." %
+                self.recipe.name,
+                view.request.response.notifications[0].message)
+            self.pushConfig(
+                "charms",
+                charmhub_secrets_private_key=base64.b64encode(
+                    bytes(private_key)).decode())
+            container = getUtility(IEncryptedContainer, "charmhub-secrets")
+            self.assertThat(self.recipe.store_secrets, MatchesDict({
+                "exchanged_encrypted": AfterPreprocessing(
+                    lambda data: container.decrypt(data).decode(),
+                    Equals(exchanged_macaroon_raw)),
+                }))
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("http://charmhub.example/v1/tokens/exchange";),
+                    method=Equals("POST"),
+                    headers=ContainsDict({
+                        "Macaroons": AfterPreprocessing(
+                            lambda v: json.loads(
+                                base64.b64decode(v.encode()).decode()),
+                            Equals([
+                                json.loads(m) for m in (
+                                    root_macaroon_raw,
+                                    discharge_macaroon_raw)])),
+                        }),
+                    body=AfterPreprocessing(
+                        lambda b: json.loads(b.decode()),
+                        Equals({})))),
+            ]))
+
+
 class TestCharmRecipeDeleteView(BaseTestCharmRecipeView):
 
     def test_unauthorized(self):
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 9cda4c5..397c2eb 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -48,6 +48,14 @@
             interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
     </class>
 
+    <!-- CharmhubSecretsEncryptedContainer -->
+    <securedutility
+        class="lp.charms.model.charmrecipe.CharmhubSecretsEncryptedContainer"
+        provides="lp.services.crypto.interfaces.IEncryptedContainer"
+        name="charmhub-secrets">
+        <allow interface="lp.services.crypto.interfaces.IEncryptedContainer" />
+    </securedutility>
+
     <!-- CharmRecipeBuild -->
     <class class="lp.charms.model.charmrecipebuild.CharmRecipeBuild">
         <require
@@ -86,6 +94,13 @@
         factory="lp.charms.model.charmrecipebuildbehaviour.CharmRecipeBuildBehaviour"
         permission="zope.Public" />
 
+    <!-- Charmhub interaction -->
+    <securedutility
+        class="lp.charms.model.charmhubclient.CharmhubClient"
+        provides="lp.charms.interfaces.charmhubclient.ICharmhubClient">
+        <allow interface="lp.charms.interfaces.charmhubclient.ICharmhubClient" />
+    </securedutility>
+
     <!-- Charm-related jobs -->
     <class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
         <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
new file mode 100644
index 0000000..8beb251
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmhubclient.py
@@ -0,0 +1,63 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface for communication with Charmhub."""
+
+__all__ = [
+    "BadExchangeMacaroonsResponse",
+    "BadRequestPackageUploadResponse",
+    "ICharmhubClient",
+    ]
+
+import http.client
+
+from lazr.restful.declarations import error_status
+from zope.interface import Interface
+
+
+class CharmhubError(Exception):
+
+    def __init__(self, message="", detail=None, can_retry=False):
+        super().__init__(message)
+        self.message = message
+        self.detail = detail
+        self.can_retry = can_retry
+
+
+@error_status(http.client.INTERNAL_SERVER_ERROR)
+class BadRequestPackageUploadResponse(CharmhubError):
+    pass
+
+
+class BadExchangeMacaroonsResponse(CharmhubError):
+    pass
+
+
+class ICharmhubClient(Interface):
+    """Interface for the API provided by Charmhub."""
+
+    def requestPackageUploadPermission(package_name):
+        """Request permission from Charmhub to upload builds of a charm.
+
+        We need the following permissions: `package-manage-revisions` (to
+        upload new blobs) and `package-manage-releases` (to release
+        revisions).
+
+        The returned macaroon will include a third-party caveat that must be
+        discharged by Candid.  This method does not acquire that discharge;
+        it must be acquired separately.
+
+        :param package_name: The registered name of this charm on Charmhub.
+        :return: A serialized macaroon appropriate for uploading builds of
+            this charm.
+        """
+
+    def exchangeMacaroons(root_macaroon_raw, unbound_discharge_macaroon_raw):
+        """Exchange root+discharge macaroons for a new Charmhub-only macaroon.
+
+        :param root_macaroon: A serialized root macaroon from Charmhub.
+        :param unbound_discharge_macaroon: A corresponding serialized
+            unbound discharge macaroon from Candid.
+        :return: A serialized macaroon from Charmhub with no third-party
+            Candid caveat.
+        """
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 0f55360..57d5ce8 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -7,6 +7,7 @@ __metaclass__ = type
 __all__ = [
     "BadCharmRecipeSource",
     "BadCharmRecipeSearchContext",
+    "CannotAuthorizeCharmhubUploads",
     "CannotFetchCharmcraftYaml",
     "CannotParseCharmcraftYaml",
     "CHARM_RECIPE_ALLOW_CREATE",
@@ -145,6 +146,11 @@ class BadCharmRecipeSearchContext(Exception):
     """The context is not valid for a charm recipe search."""
 
 
+@error_status(http_client.BAD_REQUEST)
+class CannotAuthorizeCharmhubUploads(Exception):
+    """Cannot authorize uploads of a charm to Charmhub."""
+
+
 class MissingCharmcraftYaml(Exception):
     """The repository for this charm recipe does not have a charmcraft.yaml."""
 
@@ -372,6 +378,30 @@ class ICharmRecipeView(Interface):
 class ICharmRecipeEdit(Interface):
     """`ICharmRecipe` methods that require launchpad.Edit permission."""
 
+    def beginAuthorization():
+        """Begin authorizing uploads of this charm recipe to Charmhub.
+
+        :raises CannotAuthorizeCharmhubUploads: if the charm recipe is not
+            properly configured for Charmhub uploads.
+        :raises BadRequestPackageUploadResponse: if Charmhub returns an
+            error or a response without a macaroon when asked to issue a
+            macaroon.
+        :raises BadCandidMacaroon: if the macaroon returned by Charmhub has
+            unsuitable Candid caveats.
+        :return: The serialized macaroon returned by the store.  The caller
+            should acquire a discharge macaroon for this caveat from Candid
+            and then call `completeAuthorization`.
+        """
+
+    def completeAuthorization(unbound_discharge_macaroon_raw):
+        """Complete authorizing uploads of this charm recipe to Charmhub.
+
+        :param unbound_discharge_macaroon_raw: The serialized unbound
+            discharge macaroon returned by Candid.
+        :raises CannotAuthorizeCharmhubUploads: if the charm recipe is not
+            properly configured for Charmhub uploads.
+        """
+
     def destroySelf():
         """Delete this charm recipe, provided that it has no builds."""
 
diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
new file mode 100644
index 0000000..be195d3
--- /dev/null
+++ b/lib/lp/charms/model/charmhubclient.py
@@ -0,0 +1,117 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Communication with Charmhub."""
+
+__all__ = [
+    "CharmhubClient",
+    ]
+
+from base64 import b64encode
+
+from lazr.restful.utils import get_current_browser_request
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import requests
+from zope.interface import implementer
+
+from lp.charms.interfaces.charmhubclient import (
+    BadExchangeMacaroonsResponse,
+    BadRequestPackageUploadResponse,
+    ICharmhubClient,
+    )
+from lp.services.config import config
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.services.timeout import urlfetch
+from lp.services.webapp.url import urlappend
+
+
+@implementer(ICharmhubClient)
+class CharmhubClient:
+    """A client for the API provided by Charmhub."""
+
+    @staticmethod
+    def _getTimeline():
+        # XXX cjwatson 2021-08-05: This can be simplified once jobs have
+        # timeline support.
+        request = get_current_browser_request()
+        if request is None:
+            return None
+        return get_request_timeline(request)
+
+    @classmethod
+    def _makeCharmhubError(cls, error_class, requests_error):
+        error_message = requests_error.args[0]
+        if requests_error.response.content:
+            try:
+                response_data = requests_error.response.json()
+            except ValueError:
+                pass
+            else:
+                if "error_list" in response_data:
+                    error_message = "\n".join(
+                        error["message"]
+                        for error in response_data["error_list"])
+        detail = requests_error.response.content.decode(errors="replace")
+        can_retry = requests_error.response.status_code in (502, 503)
+        return error_class(error_message, detail=detail, can_retry=can_retry)
+
+    @classmethod
+    def requestPackageUploadPermission(cls, package_name):
+        """See `ICharmhubClient`."""
+        assert config.charms.charmhub_url is not None
+        request_url = urlappend(config.charms.charmhub_url, "v1/tokens")
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "request-charm-upload-macaroon", package_name, allow_nested=True)
+        try:
+            response = urlfetch(
+                request_url, method="POST",
+                json={
+                    "description": "{} for {}".format(
+                        package_name, config.vhost.mainsite.hostname),
+                    "packages": [{"type": "charm", "name": package_name}],
+                    "permissions": [
+                        "package-manage-releases",
+                        "package-manage-revisions",
+                        ],
+                    })
+            response_data = response.json()
+            if "macaroon" not in response_data:
+                raise BadRequestPackageUploadResponse(response.text)
+            return response_data["macaroon"]
+        except requests.HTTPError as e:
+            raise cls._makeCharmhubError(BadRequestPackageUploadResponse, e)
+        finally:
+            timeline_action.finish()
+
+    @classmethod
+    def exchangeMacaroons(cls, root_macaroon_raw,
+                          unbound_discharge_macaroon_raw):
+        """See `ICharmhubClient`."""
+        assert config.charms.charmhub_url is not None
+        root_macaroon = Macaroon.deserialize(
+            root_macaroon_raw, JsonSerializer())
+        unbound_discharge_macaroon = Macaroon.deserialize(
+            unbound_discharge_macaroon_raw, JsonSerializer())
+        discharge_macaroon_raw = root_macaroon.prepare_for_request(
+            unbound_discharge_macaroon).serialize(JsonSerializer())
+        request_url = urlappend(
+            config.charms.charmhub_url, "v1/tokens/exchange")
+        request = get_current_browser_request()
+        timeline_action = get_request_timeline(request).start(
+            "exchange-macaroons", "", allow_nested=True)
+        try:
+            all_macaroons = b64encode("[{}, {}]".format(
+                root_macaroon_raw, discharge_macaroon_raw).encode()).decode()
+            response = urlfetch(
+                request_url, method="POST",
+                headers={"Macaroons": all_macaroons}, json={})
+            response_data = response.json()
+            if "macaroon" not in response_data:
+                raise BadExchangeMacaroonsResponse(response.text)
+            return response_data["macaroon"]
+        except requests.HTTPError as e:
+            raise cls._makeCharmhubError(BadExchangeMacaroonsResponse, e)
+        finally:
+            timeline_action.finish()
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 1231749..a3ce3b3 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -9,12 +9,15 @@ __all__ = [
     "get_charm_recipe_privacy_filter",
     ]
 
+import base64
 from operator import (
     attrgetter,
     itemgetter,
     )
 
 from lazr.lifecycle.event import ObjectCreatedEvent
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
 import pytz
 from storm.databases.postgres import JSON
 from storm.locals import (
@@ -49,8 +52,10 @@ from lp.buildmaster.model.builder import Builder
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.charms.adapters.buildarch import determine_instances_to_build
+from lp.charms.interfaces.charmhubclient import ICharmhubClient
 from lp.charms.interfaces.charmrecipe import (
     BadCharmRecipeSearchContext,
+    CannotAuthorizeCharmhubUploads,
     CannotFetchCharmcraftYaml,
     CannotParseCharmcraftYaml,
     CHARM_RECIPE_ALLOW_CREATE,
@@ -102,6 +107,9 @@ from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.product import Product
 from lp.registry.model.series import ACTIVE_STATUSES
+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,
@@ -126,6 +134,7 @@ from lp.services.propertycache import (
     cachedproperty,
     get_property_cache,
     )
+from lp.services.webapp.candid import extract_candid_caveat
 from lp.soyuz.model.distroarchseries import (
     DistroArchSeries,
     PocketChroot,
@@ -647,6 +656,42 @@ class CharmRecipe(StormBase):
         order_by = Desc(CharmRecipeBuild.id)
         return self._getBuilds(filter_term, order_by)
 
+    def beginAuthorization(self):
+        """See `ICharmRecipe`."""
+        if self.store_name is None:
+            raise CannotAuthorizeCharmhubUploads(
+                "Cannot authorize uploads of a charm recipe with no store "
+                "name.")
+        charmhub_client = getUtility(ICharmhubClient)
+        root_macaroon_raw = charmhub_client.requestPackageUploadPermission(
+            self.store_name)
+        # Check that the macaroon has exactly one Candid caveat.
+        extract_candid_caveat(
+            Macaroon.deserialize(root_macaroon_raw, JsonSerializer()))
+        self.store_secrets = {"root": root_macaroon_raw}
+        return root_macaroon_raw
+
+    def completeAuthorization(self, unbound_discharge_macaroon_raw):
+        """See `ICharmRecipe`."""
+        if self.store_secrets is None or "root" not in self.store_secrets:
+            raise CannotAuthorizeCharmhubUploads(
+                "beginAuthorization must be called before "
+                "completeAuthorization.")
+        try:
+            Macaroon.deserialize(
+                unbound_discharge_macaroon_raw, JsonSerializer())
+        except Exception:
+            raise CannotAuthorizeCharmhubUploads(
+                "discharge_macaroon_raw is invalid.")
+        charmhub_client = getUtility(ICharmhubClient)
+        exchanged_macaroon_raw = charmhub_client.exchangeMacaroons(
+            self.store_secrets["root"], unbound_discharge_macaroon_raw)
+        container = getUtility(IEncryptedContainer, "charmhub-secrets")
+        assert container.can_encrypt
+        self.store_secrets["exchanged_encrypted"] = removeSecurityProxy(
+            container.encrypt(exchanged_macaroon_raw.encode()))
+        self.store_secrets.pop("root", None)
+
     def destroySelf(self):
         """See `ICharmRecipe`."""
         store = IStore(self)
@@ -922,6 +967,26 @@ class CharmRecipeSet:
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
 
 
+@implementer(IEncryptedContainer)
+class CharmhubSecretsEncryptedContainer(NaClEncryptedContainerBase):
+
+    @property
+    def public_key_bytes(self):
+        if config.charms.charmhub_secrets_public_key is not None:
+            return base64.b64decode(
+                config.charms.charmhub_secrets_public_key.encode())
+        else:
+            return None
+
+    @property
+    def private_key_bytes(self):
+        if config.charms.charmhub_secrets_private_key is not None:
+            return base64.b64decode(
+                config.charms.charmhub_secrets_private_key.encode())
+        else:
+            return None
+
+
 def get_charm_recipe_privacy_filter(user):
     """Return a Storm query filter to find charm recipes visible to `user`."""
     public_filter = CharmRecipe.information_type.is_in(
diff --git a/lib/lp/charms/templates/charmrecipe-authorize.pt b/lib/lp/charms/templates/charmrecipe-authorize.pt
new file mode 100644
index 0000000..41fb95c
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-authorize.pt
@@ -0,0 +1,24 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <p metal:fill-slot="extra_info">
+      The login service will prompt you to authorize this request.
+    </p>
+    <metal:suppress-superfluous-widgets fill-slot="widgets" />
+    <div class="actions" metal:fill-slot="buttons">
+      <input tal:replace="structure view/begin_action/render" />
+      or <a tal:attributes="href view/cancel_url">Cancel</a>
+    </div>
+  </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
new file mode 100644
index 0000000..b041055
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -0,0 +1,184 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for communication with Charmhub."""
+
+import base64
+import json
+
+from lazr.restful.utils import get_current_browser_request
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import responses
+from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
+    Equals,
+    MatchesAll,
+    MatchesStructure,
+    )
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmhubclient import (
+    BadExchangeMacaroonsResponse,
+    BadRequestPackageUploadResponse,
+    ICharmhubClient,
+    )
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.services.features.testing import FeatureFixture
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class RequestMatches(MatchesStructure):
+    """Matches a request with the specified attributes."""
+
+    def __init__(self, macaroons=None, json_data=None, **kwargs):
+        kwargs = dict(kwargs)
+        if macaroons is not None:
+            headers_matcher = ContainsDict({
+                "Macaroons": AfterPreprocessing(
+                    lambda v: json.loads(
+                        base64.b64decode(v.encode()).decode()),
+                    Equals([json.loads(m) for m in macaroons])),
+                })
+            if kwargs.get("headers"):
+                headers_matcher = MatchesAll(
+                    kwargs["headers"], headers_matcher)
+            kwargs["headers"] = headers_matcher
+        if json_data is not None:
+            body_matcher = AfterPreprocessing(
+                lambda b: json.loads(b.decode()), Equals(json_data))
+            if kwargs.get("body"):
+                body_matcher = MatchesAll(kwargs["body"], body_matcher)
+            kwargs["body"] = body_matcher
+        super().__init__(**kwargs)
+
+
+class TestCharmhubClient(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.client = getUtility(ICharmhubClient)
+
+    @responses.activate
+    def test_requestPackageUploadPermission(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens";,
+            json={"macaroon": "sentinel"})
+        macaroon = self.client.requestPackageUploadPermission("test-charm")
+        self.assertThat(responses.calls[-1].request, RequestMatches(
+            url=Equals("http://charmhub.example/v1/tokens";),
+            method=Equals("POST"),
+            json_data={
+                "description": "test-charm for launchpad.test",
+                "packages": [{"type": "charm", "name": "test-charm"}],
+                "permissions": [
+                    "package-manage-releases",
+                    "package-manage-revisions",
+                    ],
+                }))
+        self.assertEqual("sentinel", macaroon)
+        request = get_current_browser_request()
+        start, stop = get_request_timeline(request).actions[-2:]
+        self.assertThat(start, MatchesStructure.byEquality(
+            category="request-charm-upload-macaroon-start",
+            detail="test-charm"))
+        self.assertThat(stop, MatchesStructure.byEquality(
+            category="request-charm-upload-macaroon-stop",
+            detail="test-charm"))
+
+    @responses.activate
+    def test_requestPackageUploadPermission_missing_macaroon(self):
+        responses.add("POST", "http://charmhub.example/v1/tokens";, json={})
+        self.assertRaisesWithContent(
+            BadRequestPackageUploadResponse, "{}",
+            self.client.requestPackageUploadPermission, "test-charm")
+
+    @responses.activate
+    def test_requestPackageUploadPermission_error(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens";,
+            status=503, json={"error_list": [{"message": "Failed"}]})
+        self.assertRaisesWithContent(
+            BadRequestPackageUploadResponse, "Failed",
+            self.client.requestPackageUploadPermission, "test-charm")
+
+    @responses.activate
+    def test_requestPackageUploadPermission_404(self):
+        responses.add("POST", "http://charmhub.example/v1/tokens";, status=404)
+        self.assertRaisesWithContent(
+            BadRequestPackageUploadResponse,
+            "404 Client Error: Not Found",
+            self.client.requestPackageUploadPermission, "test-charm")
+
+    @responses.activate
+    def test_exchangeMacaroons(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";,
+            json={"macaroon": "sentinel"})
+        root_macaroon = Macaroon(version=2)
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        unbound_discharge_macaroon = Macaroon(version=2)
+        unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+            JsonSerializer())
+        discharge_macaroon_raw = root_macaroon.prepare_for_request(
+            unbound_discharge_macaroon).serialize(JsonSerializer())
+        exchanged_macaroon_raw = self.client.exchangeMacaroons(
+            root_macaroon_raw, unbound_discharge_macaroon_raw)
+        self.assertThat(responses.calls[-1].request, RequestMatches(
+            url=Equals("http://charmhub.example/v1/tokens/exchange";),
+            method=Equals("POST"),
+            macaroons=[root_macaroon_raw, discharge_macaroon_raw],
+            json_data={}))
+        self.assertEqual("sentinel", exchanged_macaroon_raw)
+        request = get_current_browser_request()
+        start, stop = get_request_timeline(request).actions[-2:]
+        self.assertThat(start, MatchesStructure.byEquality(
+            category="exchange-macaroons-start", detail=""))
+        self.assertThat(stop, MatchesStructure.byEquality(
+            category="exchange-macaroons-stop", detail=""))
+
+    @responses.activate
+    def test_exchangeMacaroons_missing_macaroon(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";, json={})
+        root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+        discharge_macaroon_raw = Macaroon(version=2).serialize(
+            JsonSerializer())
+        self.assertRaisesWithContent(
+            BadExchangeMacaroonsResponse, "{}",
+            self.client.exchangeMacaroons,
+            root_macaroon_raw, discharge_macaroon_raw)
+
+    @responses.activate
+    def test_exchangeMacaroons_error(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";,
+            status=401,
+            json={"error_list": [{"message": "Exchange window expired"}]})
+        root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+        discharge_macaroon_raw = Macaroon(version=2).serialize(
+            JsonSerializer())
+        self.assertRaisesWithContent(
+            BadExchangeMacaroonsResponse, "Exchange window expired",
+            self.client.exchangeMacaroons,
+            root_macaroon_raw, discharge_macaroon_raw)
+
+    @responses.activate
+    def test_exchangeMacaroons_404(self):
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";, status=404)
+        root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+        discharge_macaroon_raw = Macaroon(version=2).serialize(
+            JsonSerializer())
+        self.assertRaisesWithContent(
+            BadExchangeMacaroonsResponse,
+            "404 Client Error: Not Found",
+            self.client.exchangeMacaroons,
+            root_macaroon_raw, discharge_macaroon_raw)
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 21b7a50..af961e7 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -5,18 +5,28 @@
 
 __metaclass__ = type
 
+import base64
+import json
 from textwrap import dedent
 
+from nacl.public import PrivateKey
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import responses
 from storm.locals import Store
 from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
     Equals,
     Is,
     MatchesDict,
+    MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
     )
 import transaction
 from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
@@ -35,6 +45,7 @@ from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.charms.interfaces.charmrecipe import (
     BadCharmRecipeSearchContext,
+    CannotAuthorizeCharmhubUploads,
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
     CharmRecipeBuildAlreadyPending,
@@ -59,6 +70,7 @@ from lp.code.errors import GitRepositoryBlobNotFound
 from lp.code.tests.helpers import GitHostingFixture
 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,
@@ -555,6 +567,149 @@ class TestCharmRecipe(TestCaseWithFactory):
             getUtility(ICharmRecipeSet).exists(owner, project, "condemned"))
 
 
+class TestCharmRecipeAuthorization(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.pushConfig(
+            "launchpad", candid_service_root="https://candid.test/";)
+
+    @responses.activate
+    def assertBeginsAuthorization(self, recipe, **kwargs):
+        root_macaroon = Macaroon(version=2)
+        root_macaroon.add_third_party_caveat(
+            "https://candid.test/";, "", "identity")
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens";,
+            json={"macaroon": root_macaroon_raw})
+        self.assertEqual(root_macaroon_raw, recipe.beginAuthorization())
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("http://charmhub.example/v1/tokens";),
+                    method=Equals("POST"),
+                    body=AfterPreprocessing(
+                        lambda b: json.loads(b.decode()),
+                        Equals({
+                            "description": (
+                                "{} for launchpad.test".format(
+                                    recipe.store_name)),
+                            "packages": [
+                                {"type": "charm", "name": recipe.store_name},
+                                ],
+                            "permissions": [
+                                "package-manage-releases",
+                                "package-manage-revisions",
+                                ],
+                            })))),
+            ]))
+        self.assertEqual({"root": root_macaroon_raw}, recipe.store_secrets)
+
+    def test_beginAuthorization(self):
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode())
+        with person_logged_in(recipe.registrant):
+            self.assertBeginsAuthorization(recipe)
+
+    def test_beginAuthorization_unauthorized(self):
+        # A user without edit access cannot authorize charm recipe uploads.
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode())
+        with person_logged_in(self.factory.makePerson()):
+            self.assertRaises(
+                Unauthorized, getattr, recipe, "beginAuthorization")
+
+    @responses.activate
+    def test_completeAuthorization(self):
+        private_key = PrivateKey.generate()
+        self.pushConfig(
+            "charms",
+            charmhub_secrets_public_key=base64.b64encode(
+                bytes(private_key.public_key)).decode())
+        root_macaroon = Macaroon(version=2)
+        root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+        unbound_discharge_macaroon = Macaroon(version=2)
+        unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+            JsonSerializer())
+        discharge_macaroon_raw = root_macaroon.prepare_for_request(
+            unbound_discharge_macaroon).serialize(JsonSerializer())
+        exchanged_macaroon = Macaroon(version=2)
+        exchanged_macaroon_raw = exchanged_macaroon.serialize(JsonSerializer())
+        responses.add(
+            "POST", "http://charmhub.example/v1/tokens/exchange";,
+            json={"macaroon": exchanged_macaroon_raw})
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": root_macaroon_raw})
+        with person_logged_in(recipe.registrant):
+            recipe.completeAuthorization(unbound_discharge_macaroon_raw)
+            self.pushConfig(
+                "charms",
+                charmhub_secrets_private_key=base64.b64encode(
+                    bytes(private_key)).decode())
+            container = getUtility(IEncryptedContainer, "charmhub-secrets")
+            self.assertThat(recipe.store_secrets, MatchesDict({
+                "exchanged_encrypted": AfterPreprocessing(
+                    lambda data: container.decrypt(data).decode(),
+                    Equals(exchanged_macaroon_raw)),
+                }))
+        self.assertThat(responses.calls, MatchesListwise([
+            MatchesStructure(
+                request=MatchesStructure(
+                    url=Equals("http://charmhub.example/v1/tokens/exchange";),
+                    method=Equals("POST"),
+                    headers=ContainsDict({
+                        "Macaroons": AfterPreprocessing(
+                            lambda v: json.loads(
+                                base64.b64decode(v.encode()).decode()),
+                            Equals([
+                                json.loads(m) for m in (
+                                    root_macaroon_raw,
+                                    discharge_macaroon_raw)])),
+                        }),
+                    body=AfterPreprocessing(
+                        lambda b: json.loads(b.decode()),
+                        Equals({})))),
+            ]))
+
+    def test_completeAuthorization_without_beginAuthorization(self):
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode())
+        discharge_macaroon = Macaroon(version=2)
+        with person_logged_in(recipe.registrant):
+            self.assertRaisesWithContent(
+                CannotAuthorizeCharmhubUploads,
+                "beginAuthorization must be called before "
+                "completeAuthorization.",
+                recipe.completeAuthorization,
+                discharge_macaroon.serialize(JsonSerializer()))
+
+    def test_completeAuthorization_unauthorized(self):
+        root_macaroon = Macaroon(version=2)
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": root_macaroon.serialize(JsonSerializer())})
+        with person_logged_in(self.factory.makePerson()):
+            self.assertRaises(
+                Unauthorized, getattr, recipe, "completeAuthorization")
+
+    def test_completeAuthorization_malformed_discharge_macaroon(self):
+        root_macaroon = Macaroon(version=2)
+        recipe = self.factory.makeCharmRecipe(
+            store_upload=True, store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": root_macaroon.serialize(JsonSerializer())})
+        with person_logged_in(recipe.registrant):
+            self.assertRaisesWithContent(
+                CannotAuthorizeCharmhubUploads,
+                "discharge_macaroon_raw is invalid.",
+                recipe.completeAuthorization, "nonsense")
+
+
 class TestCharmRecipeDeleteWithBuilds(TestCaseWithFactory):
 
     layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 719c002..e6242b9 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -150,6 +150,22 @@ admin_address: system-error@xxxxxxxxxxxxx
 cron_control_url: file:cronscripts.ini
 
 
+[charms]
+# Charmhub's primary URL endpoint.
+# datatype: urlbase
+charmhub_url: none
+
+# Base64-encoded NaCl private key for decrypting Charmhub upload tokens.
+# This should only be set in secret overlays on systems that need to perform
+# Charmhub uploads on behalf of users.
+# datatype: string
+charmhub_secrets_private_key: none
+
+# Base64-encoded NaCl public key for encrypting Charmhub upload tokens.
+# datatype: string
+charmhub_secrets_public_key: none
+
+
 [checkwatches]
 # The database user to run this process as.
 # datatype: string

Follow ups