diff libervia/backend/plugins/plugin_comp_ap_gateway/http_server.py @ 4259:49019947cc76

component AP Gateway: implement HTTP GET signature.
author Goffi <goffi@goffi.org>
date Wed, 05 Jun 2024 22:34:09 +0200
parents 5f2d496c633f
children 0d7bb4df2343
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_comp_ap_gateway/http_server.py	Wed Jun 05 22:33:37 2024 +0200
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/http_server.py	Wed Jun 05 22:34:09 2024 +0200
@@ -127,6 +127,7 @@
 
     async def handle_undo_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: jid.JID,
@@ -137,7 +138,7 @@
     ) -> None:
         if node is None:
             node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         object_ = data.get("object")
         if isinstance(object_, str):
             # we check first if it's not a cached object
@@ -150,10 +151,10 @@
             # because we'll undo the activity, we can remove it from cache
             await self.apg.client._ap_storage.remove(ap_cache_key)
         else:
-            objects = await self.apg.ap_get_list(data, "object")
+            objects = await self.apg.ap_get_list(requestor_actor_id, data, "object")
         for obj in objects:
             type_ = obj.get("type")
-            actor = await self.apg.ap_get_sender_actor(obj)
+            actor = await self.apg.ap_get_sender_actor(requestor_actor_id, obj)
             if actor != signing_actor:
                 log.warning(f"ignoring object not attributed to signing actor: {data}")
                 continue
@@ -188,6 +189,7 @@
 
     async def handle_follow_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: jid.JID,
@@ -198,7 +200,7 @@
     ) -> None:
         if node is None:
             node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         try:
             subscription = await self.apg._p.subscribe(
                 client,
@@ -224,6 +226,7 @@
 
     async def handle_accept_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: jid.JID,
@@ -234,8 +237,8 @@
     ) -> None:
         if node is None:
             node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
+        objects = await self.apg.ap_get_list(requestor_actor_id, data, "object")
         for obj in objects:
             type_ = obj.get("type")
             if type_ == "Follow":
@@ -273,6 +276,7 @@
 
     async def handle_delete_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -283,13 +287,14 @@
     ):
         if node is None:
             node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
+        objects = await self.apg.ap_get_list(requestor_actor_id, data, "object")
         for obj in objects:
             await self.apg.new_ap_delete_item(client, account_jid, node, obj)
 
     async def handle_new_ap_items(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -308,20 +313,26 @@
                 f"happen. Ignoring object from {signing_actor}\n{data}"
             )
             raise exceptions.DataError("unexpected field in item")
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
+        objects = await self.apg.ap_get_list(requestor_actor_id, data, "object")
         for obj in objects:
             if node is None:
                 if obj.get("type") == TYPE_EVENT:
                     node = self.apg._events.namespace
                 else:
                     node = self.apg._m.namespace
-            sender = await self.apg.ap_get_sender_actor(obj)
+            sender = await self.apg.ap_get_sender_actor(requestor_actor_id, obj)
             if repeated:
                 # we don't check sender when item is repeated, as it should be different
                 # from post author in this case
-                sender_jid = await self.apg.get_jid_from_id(sender)
-                repeater_jid = await self.apg.get_jid_from_id(signing_actor)
+                sender_jid = await self.apg.get_jid_from_id(
+                    requestor_actor_id,
+                    sender
+                )
+                repeater_jid = await self.apg.get_jid_from_id(
+                    requestor_actor_id,
+                    signing_actor
+                )
                 repeated_item_id = obj["id"]
                 if self.apg.is_local_url(repeated_item_id):
                     # the repeated object is from XMPP, we need to parse the URL to find
@@ -374,6 +385,7 @@
 
     async def handle_create_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -382,10 +394,13 @@
         ap_url: str,
         signing_actor: str
     ):
-        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
+        await self.handle_new_ap_items(
+            requestor_actor_id, request, data, account_jid, node, signing_actor
+        )
 
     async def handle_update_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -396,10 +411,13 @@
     ):
         # Update is the same as create: the item ID stays the same, thus the item will be
         # overwritten
-        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
+        await self.handle_new_ap_items(
+            requestor_actor_id, request, data, account_jid, node, signing_actor
+        )
 
     async def handle_announce_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -410,6 +428,7 @@
     ):
         # we create a new item
         await self.handle_new_ap_items(
+            requestor_actor_id,
             request,
             data,
             account_jid,
@@ -516,6 +535,7 @@
 
     async def handle_like_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -524,11 +544,12 @@
         ap_url: str,
         signing_actor: str
     ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         await self.handle_attachment_item(client, data, {"noticed": True})
 
     async def handle_emojireact_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -537,13 +558,14 @@
         ap_url: str,
         signing_actor: str
     ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         await self.handle_attachment_item(client, data, {
             "reactions": {"operation": "update", "add": [data["content"]]}
         })
 
     async def handle_join_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -552,11 +574,12 @@
         ap_url: str,
         signing_actor: str
     ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         await self.handle_attachment_item(client, data, {"rsvp": {"attending": "yes"}})
 
     async def handle_leave_activity(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: dict,
         account_jid: Optional[jid.JID],
@@ -565,7 +588,7 @@
         ap_url: str,
         signing_actor: str
     ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
+        client = await self.apg.get_virtual_client(requestor_actor_id, signing_actor)
         await self.handle_attachment_item(client, data, {"rsvp": {"attending": "no"}})
 
     async def ap_actor_request(
@@ -814,6 +837,7 @@
 
     async def ap_inbox_request(
         self,
+        requestor_actor_id: str,
         request: "HTTPRequest",
         data: Optional[dict],
         account_jid: Optional[jid.JID],
@@ -825,7 +849,7 @@
         assert data is not None
         if signing_actor is None:
             raise exceptions.InternalError("signing_actor must be set for inbox requests")
-        await self.check_signing_actor(data, signing_actor)
+        await self.check_signing_actor(requestor_actor_id, data, signing_actor)
         activity_type = (data.get("type") or "").lower()
         if not activity_type in ACTIVITY_TYPES_LOWER:
             return self.response_code(
@@ -851,7 +875,8 @@
             )
         else:
             await method(
-                request, data, account_jid, node, ap_account, ap_url, signing_actor
+                requestor_actor_id, request, data, account_jid, node, ap_account, ap_url,
+                signing_actor
             )
 
     async def ap_followers_request(
@@ -954,11 +979,73 @@
             to_log.append(f"  headers:\n{headers}")
         return to_log
 
+    def get_requestor_actor_id(
+        self,
+        data: dict|None = None,
+        uri_extra_args: list[str]|None = None
+    ) -> str:
+        """Find the actor ID of the requestor.
+
+        The requestor here is actually the local actor which will do the requests to
+        achieve the task (e.g. retrieve external actor data), not the requestor of the
+        received AP request.
+
+        It will notably be used as requestor actor ID to sign HTTP requests. We need to
+        sign GET request too to access instance checking HTTP GET signature (e.g. Mastodon
+        instances set in "secure mode").
+
+        We look for the destinee of the request and check if it's a local actor, and
+        default to a generic one if we can't find it.
+
+        Destinee is first checked in data if any, otherwise in request URI.
+
+        @param data: parsed JSON data of original AP request, if any.
+        @param uri_extra_args: arguments of the AP request as returned by
+            [self.apg.parse_apurl]. It is most of time the destinee of the request.
+        @return: requestor_actor_id to use to sign HTTP request needed to answer the
+            original request.
+        """
+        # We first check for destinee in data.
+        if data:
+            try:
+                for to_ in data["to"]:
+                    if self.apg.is_local_url(to_):
+                        url_type, url_args = self.apg.parse_apurl(to_)
+                        if url_type != TYPE_ACTOR or not url_args:
+                            continue
+                        ap_account = url_args[0]
+                        if (
+                            not ap_account.endswith(f"@{self.apg.public_url}")
+                            or ap_account.count("@") != 1
+                        ):
+                            continue
+                        return to_
+            except KeyError:
+                pass
+
+        # If nothing relevant, we try URI arguments.
+        if uri_extra_args:
+            ap_account = uri_extra_args[0]
+            if (
+                ap_account.endswith(f"@{self.apg.public_url}")
+                and ap_account.count("@") == 1
+            ):
+                return self.apg.build_apurl(TYPE_ACTOR, ap_account)
+
+        # Still nothing, we'll have to use a generic actor.
+        log.warning(
+            "Can't find destinee in \"to\" field, using generic requestor for signature."
+        )
+        return self.apg.build_apurl(
+            TYPE_ACTOR, f"libervia@{self.apg.public_url}"
+        )
+
     async def ap_request(
         self,
         request: "HTTPRequest",
-        data: Optional[dict] = None,
-        signing_actor: Optional[str] = None
+        data: dict|None = None,
+        signing_actor: str|None = None,
+        requestor_actor_id: str|None = None,
     ) -> None:
         if self.apg.verbose:
             to_log = self._get_to_log(request, data)
@@ -969,6 +1056,7 @@
             path
         )
         request_type, extra_args = self.apg.parse_apurl(ap_url)
+
         header_accept = request.getHeader("accept") or ""
         if ((MEDIA_TYPE_AP not in header_accept
              and MEDIA_TYPE_AP_ALT not in header_accept
@@ -1007,11 +1095,15 @@
                 request.finish()
                 return
 
+        if requestor_actor_id is None:
+            requestor_actor_id = self.get_requestor_actor_id(
+                data, extra_args
+            )
         if len(extra_args) == 0:
             if request_type != "shared_inbox":
                 raise exceptions.DataError(f"Invalid request type: {request_type!r}")
             ret_data = await self.ap_inbox_request(
-                request, data, None, None, None, ap_url, signing_actor
+                requestor_actor_id, request, data, None, None, None, ap_url, signing_actor
             )
         elif request_type == "avatar":
             if len(extra_args) != 1:
@@ -1034,7 +1126,7 @@
                 raise exceptions.DataError(f"Invalid request type: {request_type!r}")
             method = getattr(self, f"ap_{request_type}_request")
             ret_data = await method(
-                request, data, account_jid, node, ap_account, ap_url, signing_actor
+                requestor_actor_id, request, data, account_jid, node, ap_account, ap_url, signing_actor
             )
         if ret_data is not None:
             request.setHeader("content-type", CONTENT_TYPE_AP)
@@ -1072,6 +1164,8 @@
         else:
             request.content.seek(0)
 
+        requestor_actor_id = self.get_requestor_actor_id(data)
+
         try:
             if data["type"] == "Delete" and data["actor"] == data["object"]:
                 # we don't handle actor deletion
@@ -1084,7 +1178,7 @@
             pass
 
         try:
-            signing_actor = await self.check_signature(request)
+            signing_actor = await self.check_signature(requestor_actor_id, request)
         except exceptions.EncryptionError as e:
             if self.apg.verbose:
                 to_log = self._get_to_log(request)
@@ -1118,29 +1212,42 @@
 
         # default response code, may be changed, e.g. in case of exception
         try:
-            return await self.ap_request(request, data, signing_actor)
+            return await self.ap_request(
+                request, data, signing_actor, requestor_actor_id=requestor_actor_id
+            )
         except Exception as e:
             self._on_request_error(failure.Failure(e), request)
 
-    async def check_signing_actor(self, data: dict, signing_actor: str) -> None:
+    async def check_signing_actor(
+        self,
+        requestor_actor_id: str,
+        data: dict,
+        signing_actor: str
+    ) -> None:
         """That that signing actor correspond to actor declared in data
 
+        @param requestor_actor_id: ID of the actor doing the request.
         @param data: request payload
         @param signing_actor: actor ID of the signing entity, as returned by
             check_signature
         @raise exceptions.NotFound: no actor found in data
         @raise exceptions.EncryptionError: signing actor doesn't match actor in data
         """
-        actor = await self.apg.ap_get_sender_actor(data)
+        actor = await self.apg.ap_get_sender_actor(requestor_actor_id, data)
 
         if signing_actor != actor:
             raise exceptions.EncryptionError(
                 f"signing actor ({signing_actor}) doesn't match actor in data ({actor})"
             )
 
-    async def check_signature(self, request: "HTTPRequest") -> str:
+    async def check_signature(
+        self,
+        requestor_actor_id: str,
+        request: "HTTPRequest"
+    ) -> str:
         """Check and validate HTTP signature
 
+        @param requestor_actor_id: ID of the actor doing the request.
         @return: id of the signing actor
 
         @raise exceptions.EncryptionError: signature is not present or doesn't match
@@ -1279,6 +1386,7 @@
 
         try:
             return await self.apg.check_signature(
+                requestor_actor_id,
                 sign_data["signature"],
                 key_id,
                 headers
@@ -1291,6 +1399,7 @@
                 "see https://github.com/mastodon/mastodon/issues/18871"
             )
             return await self.apg.check_signature(
+                requestor_actor_id,
                 sign_data["signature"],
                 key_id,
                 headers