Repository: nextcloud/appstore

Issue:
API: register an app id
There should be an API route to register an app id. We need this to register a public certificate for an app name. Previously the first one that published an app release became the sole owner for the app. This allowed people to grab and distribute an app for which they had no access. 

The API should look like this:

POST /api/v1/apps

``` json
{
    "certificate": "...",
    "signature": "..."
}
```
- **certificate**: the public app certificate
- **signature**: a signature over the app name in sha256 mode that you try to register in base64, can be generated using:
  
  ```
   echo -n "news" | openssl dgst -sha512 -sign /path/to/your.key | openssl base64
  ```

Authentication should include token and basic auth.

On the server side the first thing that happens is that the signature is validated. To do this the CN from the certificate is extracted (which is equal to the app name) and then the CN is checked against the provided signature using the sha512 hash.

The next thing is that the uploaded certificate is checked if it is signed by the nextcloud cert. This means that we check if the app was confirmed to be valid for that user. For that we use the Nextcloud certificate chain.

If both checks are fine, the app is created or updated and the following values are set:
- **id**: CN of the certificate
- **name**: CN of the certificate, language english (settings.LANGUAGE_CODE)
- **summary**: CN of the certificate, language english (settings.LANGUAGE_CODE)
- **description**: CN of the certificate, language english (settings.LANGUAGE_CODE)
- **owner**: the user that made the request
- **certificate**: TextField, should include the submitted certificate

If an app is updated, all previous releases need to be deleted (since their signature does not match anymore).

The following status codes are returned:
- 201: If the app has not yet been registered
- 204: If the app was registered but the certificate was updated
- 400: Invalid signature or certificate

REST documentation should be adjusted to include the route.

@LukasReschke @janibonnevier 



Discussion:
http://www.pyopenssl.org/en/stable/api/crypto.html#certificates


Resolving patch:
diff --git a/nextcloudappstore/core/api/v1/serializers.py b/nextcloudappstore/core/api/v1/serializers.py
--- a/nextcloudappstore/core/api/v1/serializers.py
+++ b/nextcloudappstore/core/api/v1/serializers.py
@@ -138,3 +138,8 @@ class AppReleaseDownloadSerializer(serializers.Serializer):
     download = serializers.URLField(validators=[HttpsUrlValidator()])
     signature = serializers.CharField()
     nightly = serializers.BooleanField(required=False, default=False)
+
+
+class AppRegisterSerializer(serializers.Serializer):
+    certificate = serializers.CharField()
+    signature = serializers.CharField()
diff --git a/nextcloudappstore/core/api/v1/views.py b/nextcloudappstore/core/api/v1/views.py
--- a/nextcloudappstore/core/api/v1/views.py
+++ b/nextcloudappstore/core/api/v1/views.py
@@ -12,12 +12,14 @@
 from rest_framework.permissions import IsAuthenticated  # type: ignore
 from rest_framework.response import Response  # type: ignore
 from rest_framework.views import APIView
-from rest_framework.exceptions import APIException, ValidationError
+from rest_framework.exceptions import APIException, ValidationError, \
+    PermissionDenied
 
 from nextcloudappstore.core.api.v1.release.importer import AppImporter
 from nextcloudappstore.core.api.v1.release.provider import AppReleaseProvider
 from nextcloudappstore.core.api.v1.serializers import AppSerializer, \
-    AppReleaseDownloadSerializer, CategorySerializer, AppRatingSerializer
+    AppReleaseDownloadSerializer, CategorySerializer, AppRatingSerializer, \
+    AppRegisterSerializer
 from nextcloudappstore.core.certificate.validator import CertificateValidator
 from nextcloudappstore.core.facades import read_file_contents
 from nextcloudappstore.core.models import App, AppRelease, Category, AppRating
@@ -91,13 +93,45 @@ def get(self, request, *args, **kwargs):
 class AppRegisterView(APIView):
     authentication_classes = (authentication.TokenAuthentication,
                               authentication.BasicAuthentication,)
-    permission_classes = (UpdateDeletePermission, IsAuthenticated)
+    permission_classes = (IsAuthenticated,)
     throttle_classes = (PostThrottle,)
     throttle_scope = 'app_register'
 
     def post(self, request):
-        # TBD, also adjust permission classes
-        pass
+        serializer = AppRegisterSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        signature = serializer.validated_data['signature']
+        certificate = serializer.validated_data['certificate']
+
+        container = Container()
+        validator = container.resolve(CertificateValidator)
+
+        app_id = validator.get_cn(certificate)
+        created = True
+        try:
+            app = App.objects.get(id=app_id)
+            created = False
+        except App.DoesNotExist:
+            app = App.objects.create(id=app_id, owner=request.user)
+
+        if app.owner != request.user:
+            raise PermissionDenied('Only the app owner is allowed to update'
+                                   'the certificate')
+
+        chain = read_file_contents(settings.NEXTCLOUD_CERTIFICATE_LOCATION)
+        crl = read_file_contents(settings.NEXTCLOUD_CRL_LOCATION)
+        if settings.VALIDATE_CERTIFICATES:
+            validator.validate_certificate(app.certificate, chain, crl)
+            validator.validate_signature(app.certificate, signature, app_id)
+
+        app.owner = request.user
+        app.certificate = certificate.strip()
+        app.save()
+
+        if created:
+            return Response(status=201)
+        else:
+            return Response(status=204)
 
 
 class AppReleaseView(DestroyAPIView):
@@ -138,9 +172,10 @@ def post(self, request):
             validator = container.resolve(CertificateValidator)
             chain = read_file_contents(settings.NEXTCLOUD_CERTIFICATE_LOCATION)
             crl = read_file_contents(settings.NEXTCLOUD_CRL_LOCATION)
-            validator.validate_certificate(app.certificate, chain, crl)
-            validator.validate_signature(app.certificate, signature, data)
-            validator.validate_app_id(app.certificate, app_id)
+            if settings.VALIDATE_CERTIFICATES:
+                validator.validate_certificate(app.certificate, chain, crl)
+                validator.validate_signature(app.certificate, signature, data)
+                validator.validate_app_id(app.certificate, app_id)
 
             importer = container.resolve(AppImporter)
             importer.import_data('app', info['app'], None)
diff --git a/nextcloudappstore/core/certificate/validator.py b/nextcloudappstore/core/certificate/validator.py
--- a/nextcloudappstore/core/certificate/validator.py
+++ b/nextcloudappstore/core/certificate/validator.py
@@ -12,7 +12,6 @@
 
 class CertificateConfiguration:
     def __init__(self) -> None:
-        self.validate_certs = settings.VALIDATE_CERTIFICATES
         self.digest = settings.CERTIFICATE_DIGEST
 
 
@@ -41,67 +40,53 @@ def validate_signature(self, certificate: str, signature: str,
                            data: bytes) -> None:
         """
         Tests if a value is a valid certificate using SHA512
-        Logs an error if self.config.validate_certs is False
         :param certificate: the certificate to use as string
         :param signature: the signature base64 encoded string to test
         :param data: the binary file content that was signed
         :raises: InvalidSignatureException if the signature is invalid
         :return: None
         """
+        cert = self._to_cert(certificate)
+        err_msg = 'Signature is invalid'
         try:
-            cert = self._to_cert(certificate)
-            err_msg = 'Signature is invalid'
-            try:
-                result = verify(cert, b64decode(signature.encode()), data,
-                                self.config.digest)
-                if result is not None:
-                    raise InvalidSignatureException(err_msg)
-            except Exception as e:
-                raise InvalidSignatureException('%s: %s' % (err_msg, str(e)))
+            result = verify(cert, b64decode(signature.encode()), data,
+                            self.config.digest)
+            if result is not None:
+                raise InvalidSignatureException(err_msg)
         except Exception as e:
-            if self.config.validate_certs:
-                raise e
-            else:
-                logger.error(str(e))
+            raise InvalidSignatureException('%s: %s' % (err_msg, str(e)))
 
     def validate_certificate(self, certificate: str, chain: str,
                              crl: str = None) -> None:
         """
         Tests if a certificate has been signed by the chain, is not revoked
-        and has not yet been expired. Logs an error if
-        self.config.validate_certs is False
+        and has not yet been expired.
         :param certificate: the certificate to test as string
         :param chain: the certificate chain file content as string
         :param crl: the certificate revocation list file content as string
         :raises: InvalidCertificateException if the certificate is invalid
         :return: None
         """
+        # root and intermediary certificate need to be split
+        cas = pem.parse(chain.encode())
+        store = X509Store()
+        for ca in cas:
+            store.add_cert(self._to_cert(str(ca)))
+
+        cert = self._to_cert(certificate)
+        ctx = X509StoreContext(store, cert)
+        err_msg = 'Certificate is invalid'
+
+        if crl:
+            crl = load_crl(FILETYPE_PEM, crl)
+            store.add_crl(crl)
+
         try:
-            # root and intermediary certificate need to be split
-            cas = pem.parse(chain.encode())
-            store = X509Store()
-            for ca in cas:
-                store.add_cert(self._to_cert(str(ca)))
-
-            cert = self._to_cert(certificate)
-            ctx = X509StoreContext(store, cert)
-            err_msg = 'Certificate is invalid'
-
-            if crl:
-                crl = load_crl(FILETYPE_PEM, crl)
-                store.add_crl(crl)
-
-            try:
-                result = ctx.verify_certificate()
-                if result is not None:
-                    raise InvalidCertificateException(err_msg)
-            except Exception as e:
-                raise InvalidCertificateException('%s: %s' % (err_msg, str(e)))
+            result = ctx.verify_certificate()
+            if result is not None:
+                raise InvalidCertificateException(err_msg)
         except Exception as e:
-            if self.config.validate_certs:
-                raise e
-            else:
-                logger.error(str(e))
+            raise InvalidCertificateException('%s: %s' % (err_msg, str(e)))
 
     def get_cn(self, certificate: str) -> str:
         """
@@ -122,16 +107,10 @@ def validate_app_id(self, certificate: str, app_id: str) -> None:
         not match
         :return: None
         """
-        try:
-            cn = self.get_cn(certificate)
-            if cn != app_id and self.config.validate_certs:
-                msg = 'App id %s does not match cert CN %s' % (app_id, cn)
-                raise CertificateAppIdMismatchException(msg)
-        except Exception as e:
-            if self.config.validate_certs:
-                raise e
-            else:
-                logger.error(str(e))
+        cn = self.get_cn(certificate)
+        if cn != app_id:
+            msg = 'App id %s does not match cert CN %s' % (app_id, cn)
+            raise CertificateAppIdMismatchException(msg)
 
     def _to_cert(self, certificate: str) -> X509:
         return load_certificate(FILETYPE_PEM, certificate.encode())


Test patch:
diff --git a/nextcloudappstore/core/api/v1/tests/__init__.py b/nextcloudappstore/core/api/v1/tests/__init__.py
--- a/nextcloudappstore/core/api/v1/tests/__init__.py
+++ b/nextcloudappstore/core/api/v1/tests/__init__.py
@@ -3,3 +3,4 @@
 from .test_parser import *
 from .test_release_importer import *
 from .test_app_release_provider import *
+from .test_app_register import *
diff --git a/nextcloudappstore/core/api/v1/tests/api.py b/nextcloudappstore/core/api/v1/tests/api.py
--- a/nextcloudappstore/core/api/v1/tests/api.py
+++ b/nextcloudappstore/core/api/v1/tests/api.py
@@ -12,9 +12,6 @@ def setUp(self):
         self.user = get_user_model().objects.create_user(username='test',
                                                          password='test',
                                                          email='test@test.com')
-#        self.token = Token.objects.create(user=self.user)
-#        self.token.save()  # key is generated when token is saved
-
         self.api_client = APIClient()
 
     def _login(self, user='test', password='test'):
diff --git a/nextcloudappstore/core/api/v1/tests/data/certificates/news-old.crt b/nextcloudappstore/core/api/v1/tests/data/certificates/news-old.crt
new file mode 100644
--- /dev/null
+++ b/nextcloudappstore/core/api/v1/tests/data/certificates/news-old.crt
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIE8TCCAtkCAhAHMA0GCSqGSIb3DQEBCwUAMG0xCzAJBgNVBAYTAlVTMQ8wDQYD
+VQQIDAZCb3N0b24xFjAUBgNVBAoMDW93bkNsb3VkIEluYy4xNTAzBgNVBAMMLG93
+bkNsb3VkIENvZGUgU2lnbmluZyBJbnRlcm1lZGlhdGUgQXV0aG9yaXR5MB4XDTE2
+MDQyMTA4MDgxMloXDTI2MDQxOTA4MDgxMlowDzENMAsGA1UEAwwEbmV3czCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMj6vQuH7NdINz3J+UPOT5z1GHYB
+HUcryfkXPQJFTPqPyBVOmwbLQrY+dH8B2WIhulXjJiM/6PybODIh6GadBhW3gtFs
+QrfkvXBOSAZq5/y376zzss5973UGZrhx6Gp5bg9d6JNfINJBF1LisemICL+LfX+j
+VRCauLhiZRrnsMtaBQJfChkNhOXlfkV1w85pe+SLQJuhjcuq8auicBlT6mS8hcGx
+neC1pFGD51afdeU8aPYey/niDkWlXVE1XENZ6iXu3Y5ZtukoiNm3O6ErBEtB9A5J
+xDghKNSbcVyosQrbY71qsaBCkkYzqhSkqJQvRQImtE+baFo3CMtKFhq76OlQY7m7
+7MOQzkbBpzZonzKlnJmmT9pl52IOq1AglDMzuh7u/JtwSIdFkJ4dBx1Yz3d+OPFt
+uZ5NseKtm5BvUp6TQZ6ACSOTK31biVMY7H6Jj+v0EYoR0GybJD2+jEMfbStHCXDA
+hqqXvFGEB+p0jyRYtYnn763yMx5ppwYQzqg61mSiKZtMPmP3mLQMvIfU3dtRwAvG
+sVX921r7AcrR8Tc+4xf9VgHs42Cq4TSAGbVrB7dbz6QTAUxw8bS/KaXHVRtmzuwg
+JdmSqO07BViujIkV8xP0b+TMvg/3621HvEGum/01vsS/RC8lvrxl/vCmCaRwFRcm
+rse5n1LI53gmKsP3AgMBAAEwDQYJKoZIhvcNAQELBQADggIBAJhPDVy1VTS34sXh
+KIv9XnW5kPbugQdaEjNs3fiY85GgQriTlW26DliqHxWJ0DpVJr78DXjzjZi0TixO
+6pVL24EMI6wKKONEUW/8x0cUmMm/KPCk4qPivWQAMYwL0n7jWsZHJxGTsGF+/fPE
+bP5q0gFeDvHW8koErovB5bNZTSw3tGxWcgJOuq8Gj7ZlJnqEqfrz3reMusgg5+Xt
+b1BqHar//z48VGqYQr6WfWsMwSXpEIU/c+9Bw6V3xT+fJRPgR63eUlHKvVe2gtMF
+eZQpJV1Vbt/MZezrBi3ksVEX2AGrsZeuFyJ9ZMs54UzFGJ1NitG+84ZMloWTPvPC
+bEeef0cmoAiu3vxWMC85uUWwIweS+tIGI/0zMS+gyNrT+Lp3MaiqAtBA0njsbs6I
+vHPdJkUNwdjAmByGftPjSEvgb5ZwUqe74imjFNAphnvvH3fETP5XlAmHe1azOoVT
+R9DITZQJGf3sg2X2BHzYfVjvIeVWln9yFeRGfkz8BjyburrPOqmYynJz3SkLfMfL
+cnWcxmy5x8WOSSVSnMIb00bn9hqoxj9sivCdMTUsmeJWN6Xjw/uBNyaaxXZvrULi
+r4wGinqqnsL8l+u2tK48xjG16EWHNIitcIf2t9gU/lfHKWBvdCR9qdhqhq/Mmndo
+0VB6CPCMct11FJsEdRGkeYuC66I9
+-----END CERTIFICATE-----
diff --git a/nextcloudappstore/core/api/v1/tests/test_app_register.py b/nextcloudappstore/core/api/v1/tests/test_app_register.py
new file mode 100644
--- /dev/null
+++ b/nextcloudappstore/core/api/v1/tests/test_app_register.py
@@ -0,0 +1,65 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+
+from nextcloudappstore.core.api.v1.tests.api import ApiTest
+from nextcloudappstore.core.facades import read_relative_file
+from nextcloudappstore.core.models import App, AppRelease
+
+
+class AppRegisterTest(ApiTest):
+    create_url = reverse('api:v1:app-register')
+    _cert = read_relative_file(__file__,
+                               'data/certificates/news-old.crt').strip()
+
+    def _create_app(self, owner, app_id):
+        return App.objects.create(owner=owner, id=app_id)
+
+    def test_register_unauthenticated(self):
+        response = self.api_client.post(self.create_url, data={
+            'signature': 'sign',
+            'certificate': 'cert'
+        }, format='json')
+        self.assertEqual(401, response.status_code)
+
+    def test_register_unauthorized(self):
+        owner = get_user_model().objects.create_user(username='owner',
+                                                     password='owner',
+                                                     email='owner@owner.com')
+        self._create_app(owner, 'news')
+        self._login_token()
+        response = self.api_client.post(self.create_url, data={
+            'signature': 'sign',
+            'certificate': self._cert
+        }, format='json')
+        self.assertEqual(403, response.status_code)
+
+    def test_register(self):
+        self._login()
+        with self.settings(VALIDATE_CERTIFICATES=False):
+            response = self.api_client.post(self.create_url, data={
+                'signature': 'sign',
+                'certificate': self._cert
+            }, format='json')
+            self.assertEqual(201, response.status_code)
+            app = App.objects.get(id='news')
+            self.assertEqual(self.user, app.owner)
+            self.assertEqual(self._cert, app.certificate)
+
+    def test_register_update(self):
+        app = self._create_app(self.user, 'news')
+        app.certificate = 'blubb'
+        app.save()
+        AppRelease.objects.create(version='1.0.0', app=app)
+
+        self.assertEqual(1, App.objects.get(id='news').releases.all().count())
+        self._login()
+        with self.settings(VALIDATE_CERTIFICATES=False):
+            response = self.api_client.post(self.create_url, data={
+                'signature': 'sign',
+                'certificate': self._cert
+            }, format='json')
+            self.assertEqual(204, response.status_code)
+            app = App.objects.get(id='news')
+            self.assertEqual(self.user, app.owner)
+            self.assertEqual(self._cert, app.certificate)
+            self.assertEqual(0, app.releases.all().count())
diff --git a/nextcloudappstore/core/certificate/tests/test_validator.py b/nextcloudappstore/core/certificate/tests/test_validator.py
--- a/nextcloudappstore/core/certificate/tests/test_validator.py
+++ b/nextcloudappstore/core/certificate/tests/test_validator.py
@@ -23,13 +23,6 @@ def test_validate_app_id(self) -> None:
         cert = read_relative_file(__file__, 'data/certificates/app.crt')
         self.validator.validate_app_id(cert, 'folderplayer')
 
-    def test_validate_app_id_turned_off(self) -> None:
-        cert = read_relative_file(__file__, 'data/certificates/app.crt')
-        config = CertificateConfiguration()
-        config.validate_certs = False
-        validator = CertificateValidator(config)
-        validator.validate_app_id(cert, 'folderplayers')
-
     def test_validate_app_id_invalid(self) -> None:
         cert = read_relative_file(__file__, 'data/certificates/app.crt')
         with self.assertRaises(CertificateAppIdMismatchException):
@@ -62,15 +55,6 @@ def test_validate_cert_not_signed(self) -> None:
         with(self.assertRaises(InvalidCertificateException)):
             self.validator.validate_certificate(cert, chain)
 
-    def test_validate_cert_not_signed_turned_off(self) -> None:
-        cert = read_relative_file(__file__, 'data/certificates/app.crt')
-        chain = read_relative_file(__file__,
-                                   'data/certificates/nextcloud.crt')
-        config = CertificateConfiguration()
-        config.validate_certs = False
-        validator = CertificateValidator(config)
-        validator.validate_certificate(cert, chain)
-
     def test_signature(self) -> None:
         cert = read_relative_file(__file__, 'data/certificates/news-old.crt')
         sign = read_relative_file(__file__,
@@ -86,16 +70,6 @@ def test_bad_signature(self) -> None:
         with (self.assertRaises(InvalidSignatureException)):
             self.validator.validate_signature(cert, sign, checksum)
 
-    def test_bad_signature_turned_off(self) -> None:
-        cert = read_relative_file(__file__, 'data/certificates/news-old.crt')
-        sign = read_relative_file(__file__,
-                                  'data/certificates/bad-news-old-minimal.sig')
-        checksum = self._read_bin_file('data/archives/minimal.tar.gz')
-        config = CertificateConfiguration()
-        config.validate_certs = False
-        validator = CertificateValidator(config)
-        validator.validate_signature(cert, sign, checksum)
-
     def _read_bin_file(self, rel_path: str) -> bytes:
         target_path = resolve_file_relative_path(__file__, rel_path)
         with open(target_path, 'rb') as f: