%(token)s
"
msgstr ""
-#: warehouse/templates/manage/token.html:78
+#: warehouse/templates/manage/token.html:80
#, python-format
msgid ""
"Set your password to the token value, including the "
"%(prefix)s
prefix"
msgstr ""
-#: warehouse/templates/manage/token.html:84
+#: warehouse/templates/manage/token.html:86
#, python-format
msgid ""
"For example, if you are using Twine to upload "
@@ -3259,7 +3259,7 @@ msgid ""
"this:"
msgstr ""
-#: warehouse/templates/manage/token.html:94
+#: warehouse/templates/manage/token.html:96
#, python-format
msgid ""
"For example, if you are using Twine to upload "
@@ -3267,61 +3267,61 @@ msgid ""
"file like this:"
msgstr ""
-#: warehouse/templates/manage/token.html:106
+#: warehouse/templates/manage/token.html:108
msgid ""
"either a user-scoped token or a project-scoped token you want to set as "
"the default"
msgstr ""
-#: warehouse/templates/manage/token.html:111
+#: warehouse/templates/manage/token.html:113
msgid "a project token"
msgstr ""
-#: warehouse/templates/manage/token.html:113
+#: warehouse/templates/manage/token.html:115
#, python-format
msgid ""
"You can then use %(command)s
to switch to the correct token "
"when uploading to PyPI."
msgstr ""
-#: warehouse/templates/manage/token.html:119
+#: warehouse/templates/manage/token.html:121
#, python-format
msgid ""
"For further instructions on how to use this token, visit the PyPI help page."
msgstr ""
-#: warehouse/templates/manage/token.html:127
+#: warehouse/templates/manage/token.html:129
msgid "Add another token"
msgstr ""
-#: warehouse/templates/manage/token.html:134
+#: warehouse/templates/manage/token.html:136
msgid "Token name"
msgstr ""
-#: warehouse/templates/manage/token.html:143
+#: warehouse/templates/manage/token.html:145
msgid "What is this token for?"
msgstr ""
-#: warehouse/templates/manage/token.html:146
+#: warehouse/templates/manage/token.html:148
msgid "Permissions"
msgstr ""
-#: warehouse/templates/manage/token.html:157
+#: warehouse/templates/manage/token.html:159
msgid "Select scope..."
msgstr ""
-#: warehouse/templates/manage/token.html:161
+#: warehouse/templates/manage/token.html:163
msgid "Project:"
msgstr ""
-#: warehouse/templates/manage/token.html:170
+#: warehouse/templates/manage/token.html:172
msgid ""
"An API token scoped to your entire account will have upload permissions "
"for all of your current and future projects."
msgstr ""
-#: warehouse/templates/manage/token.html:173
+#: warehouse/templates/manage/token.html:175
msgid "Add token"
msgstr ""
@@ -4450,10 +4450,15 @@ msgid ""
msgstr ""
#: warehouse/templates/pages/help.html:502
+#, python-format
msgid ""
-"Advanced users may wish to inspect their token by decoding it with "
-"base64, and checking the output against the unique identifier displayed "
-"on PyPI."
+"Advanced users may wish to inspect their token by decoding it with the pypitoken
library, and checking the"
+" identifier against the unique identifier displayed on PyPI. Please make "
+"sure to keep your token identifier private, though. Knowing just the "
+"identifier of a token is not enough to impersonate the user, but it's "
+"sufficient to have a token be disabled."
msgstr ""
#: warehouse/templates/pages/help.html:506
diff --git a/warehouse/macaroons/caveats.py b/warehouse/macaroons/caveats.py
deleted file mode 100644
index accb743b8a3d..000000000000
--- a/warehouse/macaroons/caveats.py
+++ /dev/null
@@ -1,93 +0,0 @@
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import json
-
-import pymacaroons
-
-from warehouse.packaging.models import Project
-
-
-class InvalidMacaroonError(Exception):
- ...
-
-
-class Caveat:
- def __init__(self, verifier):
- self.verifier = verifier
-
- def verify(self, predicate):
- raise InvalidMacaroonError
-
- def __call__(self, predicate):
- return self.verify(predicate)
-
-
-class V1Caveat(Caveat):
- def verify_projects(self, projects):
- # First, ensure that we're actually operating in
- # the context of a package.
- if not isinstance(self.verifier.context, Project):
- raise InvalidMacaroonError(
- "project-scoped token used outside of a project context"
- )
-
- project = self.verifier.context
- if project.normalized_name in projects:
- return True
-
- raise InvalidMacaroonError(
- f"project-scoped token is not valid for project '{project.name}'"
- )
-
- def verify(self, predicate):
- try:
- data = json.loads(predicate)
- except ValueError:
- raise InvalidMacaroonError("malformatted predicate")
-
- if data.get("version") != 1:
- raise InvalidMacaroonError("invalidate version in predicate")
-
- permissions = data.get("permissions")
- if permissions is None:
- raise InvalidMacaroonError("invalid permissions in predicate")
-
- if permissions == "user":
- # User-scoped tokens behave exactly like a user's normal credentials.
- return True
-
- projects = permissions.get("projects")
- if projects is None:
- raise InvalidMacaroonError("invalid projects in predicate")
-
- return self.verify_projects(projects)
-
-
-class Verifier:
- def __init__(self, macaroon, context, principals, permission):
- self.macaroon = macaroon
- self.context = context
- self.principals = principals
- self.permission = permission
- self.verifier = pymacaroons.Verifier()
-
- def verify(self, key):
- self.verifier.satisfy_general(V1Caveat(self))
-
- try:
- return self.verifier.verify(self.macaroon, key)
- except (
- pymacaroons.exceptions.MacaroonInvalidSignatureException,
- Exception, # https://github.com/ecordell/pymacaroons/issues/50
- ):
- raise InvalidMacaroonError("invalid macaroon signature")
diff --git a/warehouse/macaroons/interfaces.py b/warehouse/macaroons/interfaces.py
index ba2ddfa942e1..4bde3c7e0a95 100644
--- a/warehouse/macaroons/interfaces.py
+++ b/warehouse/macaroons/interfaces.py
@@ -26,7 +26,7 @@ def _extract_raw_macaroon(raw_macaroon):
def find_from_raw(raw_macaroon):
"""
Returns a macaroon model from the DB from a raw macaroon, or raises
- InvalidMacaroon if not found or for malformed macaroons.
+ InvalidMacaroonError if not found or for malformed macaroons.
"""
def find_macaroon(macaroon_id):
@@ -49,10 +49,16 @@ def verify(raw_macaroon, context, principals, permission):
Raises InvalidMacaroon if the macaroon is not valid.
"""
- def create_macaroon(location, user_id, description, caveats):
+ def create_macaroon(domain, user_id, description, restrictions):
"""
- Returns a new raw (serialized) macaroon. The description provided
- is not embedded into the macaroon, only stored in the DB model.
+ Returns a tuple with:
+
+ - the raw (serialized) macaroon string,
+ - the database macaroon
+
+ The description provided is not embedded into the macaroon, only stored in the
+ DB model.
+ Restrictions has the same format as in describe_caveats.
"""
def delete_macaroon(macaroon_id):
@@ -67,3 +73,12 @@ def get_macaroon_by_description(user_id, description):
Returns None if the user doesn't have a macaroon with this description.
"""
+
+ def describe_caveats(self, caveats):
+ """
+ Given a value of macaroon caveats (like the one stored in DB), return a
+ description of the caveats, as a dict:
+
+ - No key: no restriction (user-wide token)
+ - "projects" key: contains a list of normalized project names.
+ """
diff --git a/warehouse/macaroons/models.py b/warehouse/macaroons/models.py
index 33373b7aa9e0..65473e7928bd 100644
--- a/warehouse/macaroons/models.py
+++ b/warehouse/macaroons/models.py
@@ -10,8 +10,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import os
-
from sqlalchemy import (
Column,
DateTime,
@@ -26,10 +24,6 @@
from warehouse import db
-def _generate_key():
- return os.urandom(32)
-
-
class Macaroon(db.Model):
__tablename__ = "macaroons"
@@ -55,11 +49,4 @@ class Macaroon(db.Model):
# scope of their macaroon is.
caveats = Column(JSONB, nullable=False, server_default=sql.text("'{}'"))
- # It might be better to move this default into the database, that way we
- # make it less likely that something does it incorrectly (since the
- # default would be to generate a random key). However, it appears the
- # PostgreSQL pgcrypto extension uses OpenSSL RAND_bytes if available
- # instead of urandom. This is less than optimal, and we would generally
- # prefer to just always use urandom. Thus we'll do this ourselves here
- # in our application.
- key = Column(LargeBinary, nullable=False, default=_generate_key)
+ key = Column(LargeBinary, nullable=False)
diff --git a/warehouse/macaroons/services.py b/warehouse/macaroons/services.py
index c394b74f8349..4d1a9226a826 100644
--- a/warehouse/macaroons/services.py
+++ b/warehouse/macaroons/services.py
@@ -11,43 +11,35 @@
# limitations under the License.
import datetime
-import json
+import os
import uuid
-import pymacaroons
+import pypitoken
-from pymacaroons.exceptions import MacaroonDeserializationException
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import NoResultFound
from zope.interface import implementer
from warehouse.accounts.models import User
-from warehouse.macaroons.caveats import InvalidMacaroonError, Verifier
from warehouse.macaroons.interfaces import IMacaroonService
from warehouse.macaroons.models import Macaroon
-@implementer(IMacaroonService)
-class DatabaseMacaroonService:
- def __init__(self, db_session):
- self.db = db_session
+def _generate_key():
+ return os.urandom(32)
- def _extract_raw_macaroon(self, prefixed_macaroon):
- """
- Returns the base64-encoded macaroon component of a PyPI macaroon,
- dropping the prefix.
- Returns None if the macaroon is None, has no prefix, or has the
- wrong prefix.
- """
- if prefixed_macaroon is None:
- return None
+class InvalidMacaroonError(Exception):
+ ...
- prefix, _, raw_macaroon = prefixed_macaroon.partition("-")
- if prefix != "pypi" or not raw_macaroon:
- return None
- return raw_macaroon
+TOKEN_PREFIX = "pypi"
+
+
+@implementer(IMacaroonService)
+class DatabaseMacaroonService:
+ def __init__(self, db_session):
+ self.db = db_session
def find_macaroon(self, macaroon_id):
"""
@@ -67,34 +59,27 @@ def find_macaroon(self, macaroon_id):
return dm
def _deserialize_raw_macaroon(self, raw_macaroon):
- raw_macaroon = self._extract_raw_macaroon(raw_macaroon)
-
- if raw_macaroon is None:
- raise InvalidMacaroonError("malformed or nonexistent macaroon")
-
try:
- return pymacaroons.Macaroon.deserialize(raw_macaroon)
- except (
- MacaroonDeserializationException,
- Exception, # https://github.com/ecordell/pymacaroons/issues/50
- ):
- raise InvalidMacaroonError("malformed macaroon")
+ token = pypitoken.Token.load(raw_macaroon)
+ except pypitoken.LoaderError as exc:
+ raise InvalidMacaroonError(str(exc))
+ if token.prefix != TOKEN_PREFIX:
+ raise InvalidMacaroonError(
+ f"Token has wrong prefix: {token.prefix} (expected {TOKEN_PREFIX}"
+ )
+ # We don't check the domain because it's checks as part of the signature check
+ return token
def find_userid(self, raw_macaroon):
"""
Returns the id of the user associated with the given raw (serialized)
- macaroon.
+ macaroon or None.
"""
try:
- m = self._deserialize_raw_macaroon(raw_macaroon)
+ dm = self.find_from_raw(raw_macaroon)
except InvalidMacaroonError:
return None
- dm = self.find_macaroon(m.identifier.decode())
-
- if dm is None:
- return None
-
return dm.user.id
def find_from_raw(self, raw_macaroon):
@@ -102,7 +87,7 @@ def find_from_raw(self, raw_macaroon):
Returns a DB macaroon matching the imput, or raises InvalidMacaroonError
"""
m = self._deserialize_raw_macaroon(raw_macaroon)
- dm = self.find_macaroon(m.identifier.decode())
+ dm = self.find_macaroon(m.identifier)
if not dm:
raise InvalidMacaroonError("Macaroon not found")
return dm
@@ -114,20 +99,23 @@ def verify(self, raw_macaroon, context, principals, permission):
Raises InvalidMacaroonError if the macaroon is not valid.
"""
- m = self._deserialize_raw_macaroon(raw_macaroon)
- dm = self.find_macaroon(m.identifier.decode())
+ token = self._deserialize_raw_macaroon(raw_macaroon)
+ dm = self.find_macaroon(token.identifier)
if dm is None:
raise InvalidMacaroonError("deleted or nonexistent macaroon")
- verifier = Verifier(m, context, principals, permission)
- if verifier.verify(dm.key):
- dm.last_used = datetime.datetime.now()
- return True
+ project = context.normalized_name
+
+ try:
+ token.check(key=dm.key, project=project)
+ except pypitoken.ValidationError as exc:
+ raise InvalidMacaroonError(str(exc))
- raise InvalidMacaroonError("invalid macaroon")
+ dm.last_used = datetime.datetime.now()
+ return True
- def create_macaroon(self, location, user_id, description, caveats):
+ def create_macaroon(self, domain, user_id, description, restrictions):
"""
Returns a tuple of a new raw (serialized) macaroon and its DB model.
The description provided is not embedded into the macaroon, only stored
@@ -135,19 +123,42 @@ def create_macaroon(self, location, user_id, description, caveats):
"""
user = self.db.query(User).filter(User.id == user_id).one()
- dm = Macaroon(user=user, description=description, caveats=caveats)
+ identifier = uuid.uuid4()
+ key = _generate_key()
+
+ token = pypitoken.Token.create(
+ domain=domain, identifier=str(identifier), key=key, prefix="pypi"
+ )
+ # even if projects is None, this will create a NoopRestriction. With
+ # the current implementation, we need to always have a restriction in place.
+ token.restrict(
+ # We're likely to copy restrictions into this function kwargs as-is, but
+ # it's good to avoid **restrictions here to maintain the abstraction layer.
+ # If something break because the pypitoken lib expects new arguments, we'd
+ # rather it fails here and be sure to see it in the tests.
+ projects=restrictions.get("projects", None),
+ )
+
+ dm = Macaroon(
+ id=identifier,
+ user=user,
+ key=key,
+ description=description,
+ caveats=token.restrictions[0].dump(),
+ )
self.db.add(dm)
self.db.flush()
- m = pymacaroons.Macaroon(
- location=location,
- identifier=str(dm.id),
- key=dm.key,
- version=pymacaroons.MACAROON_V2,
- )
- m.add_first_party_caveat(json.dumps(caveats))
- serialized_macaroon = f"pypi-{m.serialize()}"
- return serialized_macaroon, dm
+ return token.dump(), dm
+
+ def describe_caveats(self, caveats):
+ description = {}
+ restriction = pypitoken.Restriction.load(caveats)
+
+ if isinstance(restriction, pypitoken.ProjectsRestriction):
+ description["projects"] = restriction.projects
+
+ return description
def delete_macaroon(self, macaroon_id):
"""
diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py
index 4bda8d26ab32..1b4216575d14 100644
--- a/warehouse/manage/forms.py
+++ b/warehouse/manage/forms.py
@@ -252,7 +252,7 @@ def validate_token_scope(self, field):
raise wtforms.ValidationError("Specify the token scope")
if scope_kind == "user":
- self.validated_scope = scope_kind
+ self.validated_restrictions = {}
return
try:
@@ -267,7 +267,7 @@ def validate_token_scope(self, field):
f"Unknown or invalid project name: {scope_value}"
)
- self.validated_scope = {"projects": [scope_value]}
+ self.validated_restrictions = {"projects": [scope_value]}
class DeleteMacaroonForm(UsernameMixin, PasswordMixin, forms.Form):
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
index 585998e81ce0..edc942fab99b 100644
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -131,6 +131,7 @@ def __init__(self, request):
self.breach_service = request.find_service(
IPasswordBreachedService, context=None
)
+ self.macaroon_service = request.find_service(IMacaroonService, context=None)
@property
def active_projects(self):
@@ -154,6 +155,7 @@ def default_response(self):
breach_service=self.breach_service,
),
"active_projects": self.active_projects,
+ "describe_caveats": self.macaroon_service.describe_caveats,
}
@view_config(request_method="GET")
@@ -798,6 +800,7 @@ def default_response(self):
user_service=self.user_service,
macaroon_service=self.macaroon_service,
),
+ "describe_caveats": self.macaroon_service.describe_caveats,
}
@view_config(request_method="GET")
@@ -821,12 +824,12 @@ def create_macaroon(self):
response = {**self.default_response}
if form.validate():
- macaroon_caveats = {"permissions": form.validated_scope, "version": 1}
+ restrictions = form.validated_restrictions
serialized_macaroon, macaroon = self.macaroon_service.create_macaroon(
- location=self.request.domain,
+ domain=self.request.domain,
user_id=self.request.user.id,
description=form.description.data,
- caveats=macaroon_caveats,
+ restrictions=restrictions,
)
self.user_service.record_event(
self.request.user.id,
@@ -834,14 +837,14 @@ def create_macaroon(self):
ip_address=self.request.remote_addr,
additional={
"description": form.description.data,
- "caveats": macaroon_caveats,
+ "caveats": macaroon.caveats,
},
)
- if "projects" in form.validated_scope:
+ if "projects" in restrictions:
projects = [
project
for project in self.request.user.projects
- if project.normalized_name in form.validated_scope["projects"]
+ if project.normalized_name in restrictions["projects"]
]
for project in projects:
# NOTE: We don't disclose the full caveats for this token
@@ -885,12 +888,12 @@ def delete_macaroon(self):
ip_address=self.request.remote_addr,
additional={"macaroon_id": form.macaroon_id.data},
)
- if "projects" in macaroon.caveats["permissions"]:
+ restrictions = self.macaroon_service.describe_caveats(macaroon.caveats)
+ if "projects" in restrictions:
projects = [
project
for project in self.request.user.projects
- if project.normalized_name
- in macaroon.caveats["permissions"]["projects"]
+ if project.normalized_name in restrictions["projects"]
]
for project in projects:
project.record_event(
diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html
index bb9692c2aba3..b25e2d0ec388 100644
--- a/warehouse/templates/manage/account.html
+++ b/warehouse/templates/manage/account.html
@@ -158,6 +158,7 @@
{% endmacro %}
{% macro api_row(macaroon) -%}
+ {% set restrictions = describe_caveats(macaroon.caveats) %}
{% trans %}Permissions:{% endtrans %} {% trans %}Upload packages{% endtrans %}
- {% if macaroon.caveats.permissions == "user" %}
- {% trans %}Scope:{% endtrans %} {% trans %}Entire account (all projects){% endtrans %}
+ {% trans %}Scope:{% endtrans %}
+ {% if not restrictions %}
+ {% trans %}Entire account (all projects){% endtrans %}
{% else %}
- {% trans %}Scope:{% endtrans %} {% trans project=macaroon.caveats.permissions.projects[0] %}Project "{{ project }}"{% endtrans %}
+ {% trans project=restrictions.projects[0] %}Project "{{ project }}"{% endtrans %}
{% endif %}
{{ prefix }}
prefix{% endtrans %}{% trans trimmed href='https://pypi.org/project/twine/', filename='$HOME/.pypirc' %} diff --git a/warehouse/templates/pages/help.html b/warehouse/templates/pages/help.html index 5734616d2f76..423006fb9311 100644 --- a/warehouse/templates/pages/help.html +++ b/warehouse/templates/pages/help.html @@ -499,7 +499,7 @@
.pypirc
file, while others may need to update their CI configuration file (e.g. .travis.yml
if you are using Travis).
{% endtrans %}
- {% trans %}Advanced users may wish to inspect their token by decoding it with base64, and checking the output against the unique identifier displayed on PyPI.{% endtrans %}
+{% trans trimmed pypitoken_href='https://pypitoken.readthedocs.io/', title=gettext('External link') %}Advanced users may wish to inspect their token by decoding it with the pypitoken
library, and checking the identifier against the unique identifier displayed on PyPI. Please make sure to keep your token identifier private, though. Knowing just the identifier of a token is not enough to impersonate the user, but it's sufficient to have a token be disabled.{% endtrans %}