diff --git a/app/activity_pub_service.go b/app/activity_pub_service.go index 7689f66..2e18d13 100644 --- a/app/activity_pub_service.go +++ b/app/activity_pub_service.go @@ -1,10 +1,12 @@ package app import ( + "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "io" "log/slog" "net/http" @@ -17,6 +19,7 @@ import ( "time" vocab "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" "github.com/go-fed/httpsig" ) @@ -42,7 +45,10 @@ func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc mode func (cfg *ActivityPubConfig) PrivateKey() *rsa.PrivateKey { block, _ := pem.Decode([]byte(cfg.PrivateKeyPem)) - privKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes) + privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + slog.Error("error x509.ParsePKCS1PrivateKey", "err", err) + } return privKey } @@ -131,7 +137,13 @@ func (s *ActivityPubService) sign(privateKey *rsa.PrivateKey, pubKeyId string, b return err } -func (s *ActivityPubService) GetActor(reqUrl string, fromGame string) (vocab.Actor, error) { +func (s *ActivityPubService) GetActor(reqUrl string) (vocab.Actor, error) { + + siteConfig := model.SiteConfig{} + apConfig := ActivityPubConfig{} + s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig) + s.configRepo.Get(config.SITE_CONFIG, &siteConfig) + c := http.Client{} parsedUrl, err := url.Parse(reqUrl) @@ -145,11 +157,6 @@ func (s *ActivityPubService) GetActor(reqUrl string, fromGame string) (vocab.Act req.Header.Set("Date", time.Now().Format(http.TimeFormat)) req.Header.Set("Host", parsedUrl.Host) - siteConfig := model.SiteConfig{} - apConfig := ActivityPubConfig{} - s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig) - s.configRepo.Get(config.SITE_CONFIG, &siteConfig) - err = s.sign(apConfig.PrivateKey(), siteConfig.FullUrl+"/activitypub/actor#main-key", nil, req) if err != nil { slog.Error("Signing error", "err", err) @@ -188,7 +195,9 @@ func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) err s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig) s.configRepo.Get(config.SITE_CONFIG, &siteConfig) - actor, err := s.GetActor(sender, siteConfig.FullUrl+"/activitypub/actor") + slog.Info("verifying for", "sender", sender, "retriever", siteConfig.FullUrl+"/activitypub/actor") + + actor, err := s.GetActor(sender) // actor does not have a pub key -> don't verify if actor.PublicKey.PublicKeyPem == "" { return nil @@ -213,3 +222,67 @@ func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) err } return verifier.Verify(pubKey, httpsig.RSA_SHA256) } + +func (s *ActivityPubService) Accept(act *vocab.Activity) error { + actor, err := s.GetActor(act.Actor.GetID().String()) + if err != nil { + return err + } + + accept := vocab.AcceptNew(vocab.IRI("TODO"), act) + data, err := jsonld.WithContext( + jsonld.IRI(vocab.ActivityBaseURI), + ).Marshal(accept) + + if err != nil { + slog.Error("marshalling error", "err", err) + return err + } + + return s.sendObject(actor, data) +} + +func (s *ActivityPubService) sendObject(to vocab.Actor, data []byte) error { + siteConfig := model.SiteConfig{} + apConfig := ActivityPubConfig{} + s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig) + s.configRepo.Get(config.SITE_CONFIG, &siteConfig) + + if to.Inbox == nil { + slog.Error("actor has no inbox", "actor", to) + return errors.New("actor has no inbox") + } + + actorUrl, err := url.Parse(to.Inbox.GetID().String()) + if err != nil { + slog.Error("parse error", "err", err) + return err + } + + c := http.Client{} + req, _ := http.NewRequest("POST", to.Inbox.GetID().String(), bytes.NewReader(data)) + req.Header.Set("Accept", "application/ld+json") + req.Header.Set("Date", time.Now().Format(http.TimeFormat)) + req.Header.Set("Host", actorUrl.Host) + err = s.sign(apConfig.PrivateKey(), siteConfig.FullUrl+"/activitypub/actor#main-key", data, req) + if err != nil { + slog.Error("Signing error", "err", err) + return err + } + resp, err := c.Do(req) + if err != nil { + slog.Error("Sending error", "url", req.URL, "err", err) + return err + } + slog.Info("Request", "host", resp.Request.Header) + + if resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + slog.Error("Error sending Note", "method", resp.Request.Method, "url", resp.Request.URL, "status", resp.Status, "body", string(body)) + return err + } + body, _ := io.ReadAll(resp.Body) + slog.Info("Sent Body", "body", string(data)) + slog.Info("Retrieved", "status", resp.Status, "body", string(body)) + return nil +} diff --git a/e2e_tests/mock_masto/main.py b/e2e_tests/mock_masto/main.py index 959ccce..692e75c 100644 --- a/e2e_tests/mock_masto/main.py +++ b/e2e_tests/mock_masto/main.py @@ -52,23 +52,23 @@ def webfinger(): { "subject": "acct:h4kor@mock_masto", "aliases": [ - "http://mock_masto/@h4kor", - "http://mock_masto/users/h4kor", + "http://mock_masto:8000/@h4kor", + "http://mock_masto:8000/users/h4kor", ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", - "href": "http://mock_masto/@h4kor", + "href": "http://mock_masto:8000/@h4kor", }, { "rel": "self", "type": "application/activity+json", - "href": "http://mock_masto/users/h4kor", + "href": "http://mock_masto:8000/users/h4kor", }, { "rel": "http://ostatus.org/schema/1.0/subscribe", - "template": "http://mock_masto/authorize_interaction?uri={uri}", + "template": "http://mock_masto:8000/authorize_interaction?uri={uri}", }, { "rel": "http://webfinger.net/rel/avatar", @@ -120,53 +120,53 @@ def actor(): "focalPoint": {"@container": "@list", "@id": "toot:focalPoint"}, }, ], - "id": "http://mock_masto/users/h4kor", + "id": "http://mock_masto:8000/users/h4kor", "type": "Person", - "following": "http://mock_masto/users/h4kor/following", - "followers": "http://mock_masto/users/h4kor/followers", - "inbox": "http://mock_masto/users/h4kor/inbox", - "outbox": "http://mock_masto/users/h4kor/outbox", - "featured": "http://mock_masto/users/h4kor/collections/featured", - "featuredTags": "http://mock_masto/users/h4kor/collections/tags", + "following": "http://mock_masto:8000/users/h4kor/following", + "followers": "http://mock_masto:8000/users/h4kor/followers", + "inbox": "http://mock_masto:8000/users/h4kor/inbox", + "outbox": "http://mock_masto:8000/users/h4kor/outbox", + "featured": "http://mock_masto:8000/users/h4kor/collections/featured", + "featuredTags": "http://mock_masto:8000/users/h4kor/collections/tags", "preferredUsername": "h4kor", "name": "Niko", - "summary": '

Teaching computers to do things with arguable efficiency.

he/him

', - "url": "http://mock_masto/@h4kor", + "summary": '

Teaching computers to do things with arguable efficiency.

he/him

', + "url": "http://mock_masto:8000/@h4kor", "manuallyApprovesFollowers": False, "discoverable": True, "indexable": False, "published": "2018-08-16T00:00:00Z", "memorial": False, - "devices": "http://mock_masto/users/h4kor/collections/devices", + "devices": "http://mock_masto:8000/users/h4kor/collections/devices", "publicKey": { - "id": "http://mock_masto/users/h4kor#main-key", - "owner": "http://mock_masto/users/h4kor", + "id": "http://mock_masto:8000/users/h4kor#main-key", + "owner": "http://mock_masto:8000/users/h4kor", "publicKeyPem": PUB_KEY_PEM, }, "tag": [ { "type": "Hashtag", - "href": "http://mock_masto/tags/politics", + "href": "http://mock_masto:8000/tags/politics", "name": "#politics", }, { "type": "Hashtag", - "href": "http://mock_masto/tags/climate", + "href": "http://mock_masto:8000/tags/climate", "name": "#climate", }, { "type": "Hashtag", - "href": "http://mock_masto/tags/vegan", + "href": "http://mock_masto:8000/tags/vegan", "name": "#vegan", }, { "type": "Hashtag", - "href": "http://mock_masto/tags/programming", + "href": "http://mock_masto:8000/tags/programming", "name": "#programming", }, { "type": "Hashtag", - "href": "http://mock_masto/tags/cooking", + "href": "http://mock_masto:8000/tags/cooking", "name": "#cooking", }, ], @@ -188,7 +188,7 @@ def actor(): "value": 'git.libove.org/h4kor/owl-blogs', }, ], - "endpoints": {"sharedInbox": "http://mock_masto/inbox"}, + "endpoints": {"sharedInbox": "http://mock_masto:8000/inbox"}, "icon": { "type": "Image", "mediaType": "image/png", @@ -198,12 +198,17 @@ def actor(): ) -@app.route("/users/h4kor/inbox") +@app.route("/users/h4kor/inbox", methods=["POST"]) def inbox(): if request.method == "POST": - INBOX.append(request.get_json()) + INBOX.append(json.loads(request.get_data())) return "" +@app.route("/msgs") +def msgs(): + return json.dumps(INBOX) + + if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port="8000") diff --git a/e2e_tests/tests/fixtures.py b/e2e_tests/tests/fixtures.py index 2f69ff4..c4481a8 100644 --- a/e2e_tests/tests/fixtures.py +++ b/e2e_tests/tests/fixtures.py @@ -1,7 +1,13 @@ +from contextlib import contextmanager from datetime import datetime, timezone import json +from time import sleep from urllib.parse import urlparse - +import requests, base64, hashlib +from urllib.parse import urlparse +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding ACCT_NAME = "acct:blog@localhost:3000" @@ -36,49 +42,23 @@ K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2 def ensure_follow(client, inbox_url, actor_url): - resp = client.post( + req = sign( + "POST", inbox_url, - json={ + { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", + "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", "type": "Follow", "actor": "http://mock_masto:8000/users/h4kor", "object": actor_url, }, - headers={"Content-Type": "application/activity+json"}, ) + resp = requests.Session().send(req) + assert resp.status_code == 200 -def get_gmt_now() -> str: - return datetime.now(datetime.UTC).strftime("%a, %d %b %Y %H:%M:%S GMT") - - -from http_message_signatures import ( - HTTPMessageSigner, - HTTPMessageVerifier, - HTTPSignatureKeyResolver, - algorithms, -) -import requests, base64, hashlib, http_sfv - - -class MyHTTPSignatureKeyResolver(HTTPSignatureKeyResolver): - keys = {"my-key": b"top-secret-key"} - - def resolve_public_key(self, key_id: str): - return self.keys[key_id] - - def resolve_private_key(self, key_id: str): - return priv_key - # return PRIV_KEY_PEM - - def sign(method, url, data): - from urllib.parse import urlparse - from cryptography.hazmat.primitives.serialization import load_pem_private_key - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import padding priv_key = load_pem_private_key(PRIV_KEY_PEM.encode(), None) body = json.dumps(data).encode() @@ -103,6 +83,20 @@ date: {date}""".encode() request.headers["Host"] = host request.headers["Date"] = date request.headers["Signature"] = ( - f'keyId="http://mock_masto/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"' + f'keyId="http://mock_masto:8000/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"' ) return request + + +@contextmanager +def msg_inc(n): + resp = requests.get("http://localhost:8000/msgs") + data = resp.json() + msgs = len(data) + yield + sleep(0.2) + resp = requests.get("http://localhost:8000/msgs") + data = resp.json() + assert msgs + n == len( + data + ), f"prev: {msgs}, now: {len(data)}, expected: {msgs + n}" diff --git a/e2e_tests/tests/test_activity_pub.py b/e2e_tests/tests/test_activity_pub.py index 67deaf9..1b543e0 100644 --- a/e2e_tests/tests/test_activity_pub.py +++ b/e2e_tests/tests/test_activity_pub.py @@ -1,6 +1,7 @@ from pprint import pprint +from time import sleep import requests -from .fixtures import ensure_follow, sign +from .fixtures import ensure_follow, msg_inc, sign import pytest @@ -28,57 +29,59 @@ def test_actor(client, actor_url): def test_following(client, inbox_url, followers_url, actor_url): - req = sign( - "POST", - inbox_url, - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", - "type": "Follow", - "actor": "http://mock_masto:8000/users/h4kor", - "object": actor_url, - }, - ) - pprint(req.headers) - resp = requests.Session().send(req) + with msg_inc(1): + req = sign( + "POST", + inbox_url, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", + "type": "Follow", + "actor": "http://mock_masto:8000/users/h4kor", + "object": actor_url, + }, + ) + resp = requests.Session().send(req) - assert resp.status_code == 200 + assert resp.status_code == 200 - resp = client.get( - followers_url, headers={"Content-Type": "application/activity+json"} - ) - assert resp.status_code == 200 - data = resp.json() - pprint(data) - assert "items" in data - assert len(data["items"]) == 1 + resp = client.get( + followers_url, headers={"Content-Type": "application/activity+json"} + ) + assert resp.status_code == 200 + data = resp.json() + pprint(data) + assert "items" in data + assert len(data["items"]) == 1 -# def test_unfollow(client, inbox_url, followers_url, actor_url): -# ensure_follow(client, inbox_url, actor_url) +def test_unfollow(client, inbox_url, followers_url, actor_url): + ensure_follow(client, inbox_url, actor_url) + with msg_inc(1): + req = sign( + "POST", + inbox_url, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo", + "type": "Undo", + "actor": "http://mock_masto:8000/users/h4kor", + "object": { + "id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", + "type": "Follow", + "actor": "http://mock_masto:8000/users/h4kor", + "object": actor_url, + }, + }, + ) + resp = requests.Session().send(req) + assert resp.status_code == 200 -# resp = client.post( -# inbox_url, -# json={ -# "@context": "https://www.w3.org/ns/activitystreams", -# "id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo", -# "type": "Undo", -# "actor": "http://mock_masto:8000/users/h4kor", -# "object": { -# "id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588", -# "type": "Follow", -# "actor": "http://mock_masto:8000/users/h4kor", -# "object": actor_url, -# }, -# }, -# headers={"Content-Type": "application/activity+json"}, -# ) -# assert resp.status_code == 200 - -# resp = client.get( -# followers_url, headers={"Content-Type": "application/activity+json"} -# ) -# assert resp.status_code == 200 -# data = resp.json() -# assert "items" in data -# assert len(data["items"]) == 0 + resp = client.get( + followers_url, headers={"Content-Type": "application/activity+json"} + ) + assert resp.status_code == 200 + data = resp.json() + pprint(data) + assert "totalItems" in data + assert data["totalItems"] == 0 diff --git a/web/activity_pub_handler.go b/web/activity_pub_handler.go index 5fb0a98..4e7861a 100644 --- a/web/activity_pub_handler.go +++ b/web/activity_pub_handler.go @@ -149,7 +149,7 @@ func (s *ActivityPubServer) processFollow(r *http.Request, act *vocab.Activity) return err } - // go acpub.Accept(gameName, act) + go s.apService.Accept(act) return nil }