#+title: Show ** 名正言顺 *** Nodeinfo #+begin_src python from flask import Flask, Response, jsonify from config import BASE_URL #https://alice.me app = Flask(__name__) @app.route("/.well-known/nodeinfo") def well_known_nodeinfo() -> Response: """Return nodeinfo path.""" return jsonify( { "links": [ { "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", "href": f"{BASE_URL}/nodeinfo/2.0", } ] } ) @app.get("/nodeinfo/2.0") def nodeinfo() -> Response: """Return nodeinfo.""" return jsonify( { "version": "2.0", "software": { "name": "COSCUP-demo", "version": "0.0.1", }, "protocols": ["activitypub"], "services": {"inbound": [], "outbound": []}, "usage": {"users": {"total": 1}}, "openRegistrations": False, "metadata": {}, }, ) #+end_src #+begin_src restclient GET http://coscup.localhost/.well-known/nodeinfo #+end_src #+RESULTS: #+BEGIN_SRC js // GET http://coscup.localhost/.well-known/nodeinfo // HTTP/1.1 503 Service Unavailable // Connection: close // Proxy-Connection: close // Content-Length: 0 // Request duration: 2.318918s #+END_SRC #+RESULTS: #+begin_src restclient GET http://coscup.localhost/nodeinfo/2.0 #+end_src #+RESULTS: #+BEGIN_SRC js // GET https://alice.me/nodeinfo/2.0 { "version": "2.0" "openRegistrations": false, "protocols": ["activitypub"], "software": { "name": "COSCUP-demo", "version": "0.0.1" }, "usage": { "users": { "total": 1 } }, } // HTTP/1.1 200 OK // Server: nginx/1.24.0 // Date: Fri, 14 Jul 2023 07:35:20 GMT // Content-Type: application/json // Content-Length: 297 // Connection: keep-alive // Request duration: 0.002971s #+END_SRC *** Actor #+begin_src python AS_CTX = "https://www.w3.org/ns/activitystreams" AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public" AS_EXTENDED_CTX = [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { # AS ext "Hashtag": "as:Hashtag", "sensitive": "as:sensitive", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"}, "movedTo": {"@id": "as:movedTo", "@type": "@id"}, # toot "toot": "http://joinmastodon.org/ns#", "featured": {"@id": "toot:featured", "@type": "@id"}, "Emoji": "toot:Emoji", "blurhash": "toot:blurhash", "votersCount": "toot:votersCount", }, ] ME = { "@context": AS_EXTENDED_CTX, "type": "Person", "id": config.ID, "following": config.BASE_URL + "/following", "followers": config.BASE_URL + "/followers", "featured": config.BASE_URL + "/featured", "inbox": config.BASE_URL + "/inbox", "outbox": config.BASE_URL + "/outbox", "preferredUsername": config.USERNAME, "name": config.NICKNAME, "summary": config.ACTOR_SUMMARY, "endpoints": { "sharedInbox": config.BASE_URL + "/inbox", }, "url": config.ID + "/", # Important for Mastodon "manuallyApprovesFollowers": config.MANUALLY_APPROVES_FOLLOWERS, "attachment": [], "icon": { "mediaType": "image/png", "type": "Image", "url": config.AVATAR_URL, }, "publicKey": { "id": f"{config.ID}#main-key", "owner": config.ID, "publicKeyPem": get_pubkey_as_pem(config.KEY_PATH), }, "tag": [], } #+end_src #+begin_src python @app.route(f"/user/{config.USERNAME}") # /user/show def locate_user() -> Response: """Return user ActivityPub response.""" resp = jsonify(ME) resp.headers["Content-Type"] = "application/activity+json" return resp #+end_src #+begin_src restclient GET http://coscup.localhost/user/show #+end_src ** WebFinger #+begin_src python from flask import Flask, abort, request, Response, jsonify from config import USERNAME, DOMAIN, ID #USERNAME = "show" #DOMAIN = "coscup.localhost" #ID = f"http://{DOMAIN}/user/{USERNAME}" @app.route("/.well-known/webfinger") def wellknown_webfinger() -> Response: """Exposes servers WebFinger data.""" resource = request.args.get("resource") if resource not in [f"acct:{USERNAME}@{DOMAIN}", config.ID]: abort(404) resp = jsonify( { "subject": f"acct:{config.USERNAME}@{config.DOMAIN}", "aliases": [config.ID], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": config.ID, }, { "rel": "self", "type": "application/activity+json", "href": config.ID, }, ], }, ) resp.headers["Access-Control-Allow-Origin"] = "*" resp.headers["Content-Type"] = "application/jrd+json; charset=utf-8" return resp #+end_src #+begin_src restclient GET http://coscup.localhost/.well-known/webfinger?resource=acct:show@coscup.localhost #+end_src ** HTTP Signature other.localhost --Follow-> coscup.localhost #+begin_src json # request.data { "@context": "https://www.w3.org/ns/activitystreams", "id": "http://other.localhost/9ef8847d72434cff82254f36ff88e710", "type": "Follow", "actor": "http://other.localhost/accounts/other", "object": "http://coscup.localhost/meow/show" } # request.header { "host":"other.localhost", "accept":"*/*", "accept-encoding":"gzip, deflate, br", "connection":"keep-alive", "user-agent":"something/fediverse+app", "content-type":"application/activity+json", "content-length":"206", "date":"Tue, 11 Jul 2023 04:21:11 GMT", "digest":"SHA-256=WzNniTyRBcZpgK7a6zYJBgsdKIQmFEPPfysh/ZzKb2o=", "signature":"keyId=\"http://other.localhost#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) user-agent host date digest content-type\",signature=\"irioDQuhYstSvafpl6DW4d31wDPRiTv7MZGyBo3j4kkc2TrfOweH3WRMMnoaWwl4LAI2WYLoKefeQpOg7Rm7ZEffsoLOzZvgdWJBm8lnOEgieyy5l2Vq1mlcS2PRJCisYGdzAwFOBkcHk0WKAZXvs1ieRV34NHfM8JF+DjrCBTZ/U9LyxULBwC6tPQTh9tflCOwXZOzXUq17C+2Uzsr8h4tDHjbmrG7OAcvYiPeOUKaP+InoE6j9ViHllhidNCPL0y8b1c7c72ruN48kF42OfyfUeiuCcuLwdp8eYBlTdG/ZsT2YXyKruwim3tTD1TtyW4Vfll+F/4/1RfWHsc9LrQ==\"" } #+end_src #+begin_src python def parse_signature( signature: str ) -> dict: """Parse signature string in headers.""" detail = {} for item in signature.split(','): name, value = item.split('=', 1) value = value.strip('"') detail[name.lower()] = value signature_details = { "headers": detail["headers"].split(), "signature": detail["signature"], "algorithm": detail["algorithm"], "keyid": detail["keyid"], } return signature_details #+end_src parsed_signature = parse_signature(headers["signature"]) #+begin_src json {"algorithm": "rsa-sha256", "headers": ["(request-target)", "user-agent", "host", "date", "digest", "content-type"], "keyid": "http://other.localhost#main-key", "signature": "irioDQuhYstSvafpl6DW4d31wDPRiTv7MZGyBo3j4kkc2TrfOweH3WRMMnoaWwl4LAI2WYLoKefeQpOg7Rm7ZEffsoLOzZvgdWJBm8lnOEgieyy5l2Vq1mlcS2PRJCisYGdzAwFOBkcHk0WKAZXvs1ieRV34NHfM8JF+DjrCBTZ/U9LyxULBwC6tPQTh9tflCOwXZOzXUq17C+2Uzsr8h4tDHjbmrG7OAcvYiPeOUKaP+InoE6j9ViHllhidNCPL0y8b1c7c72ruN48kF42OfyfUeiuCcuLwdp8eYBlTdG/ZsT2YXyKruwim3tTD1TtyW4Vfll+F/4/1RfWHsc9LrQ=="} #+end_src #+begin_src python import base64 from Crypto.Hash import SHA256 def calculate_digest( payload: bytes, ) -> str: """Calculate digest for given HTTP payload.""" payload_digest = SHA256.new() payload_digest.update(body) return "SHA-256=" + \ base64.b64encode(body_digest.digest()).decode("utf-8") def build_signature_string( method: str, path: str, sign_headers: list, payload_digest: str | None, headers, ) -> str : sign_str = [] for sign_header in sign_headers: if sign_header == "(request-target)": sign_str.append("(request-target): " + method.lower() + ' ' + path) elif sign_header == "digest" and payload_digest: sign_str.append("digest: " + payload_digest) else: sign_str.append(sign_header + ": " + headers[sign_header]) return "\n".join(sign_str) signature_string = build_signature_string( request.method # "post" request.path # "/inbox" parsed_signature["headers"], # ["(request-target)", "user-agent", "host", "date", "digest", "content-type"] calculate_digest(request.data), headers, ) #+end_src signature_string #+begin_quote (request-target): post /inbox user-agent: something/fediverse+app host: other.localhost date: Tue, 11 Jul 2023 04:21:11 GMT digest: SHA-256=WzNniTyRBcZpgK7a6zYJBgsdKIQmFEPPfysh/ZzKb2o= content-type: application/activity+json #+end_quote #+begin_src python from Crypto.Hash import SHA256 from Crypto.Signature import PKCS1_v1_5 from Crypto.PublicKey import RSA def verify_signature( signature_string: str, signature: bytes, # base64.b64decode(parsed_signature["signature"]) pubkey: str, ) -> bool : pubkey = RSA.importKey(pubkey) signer = PKCS1_v1_5.new(pubkey) digest = SHA256.new() digest.update(signature_string.encode("utf-8")) return signer.verify(digest, signature) #+end_src ** Activity! *** Follow / Accept other.localhost --Follow-> coscup.localhost #+begin_src json { "@context": "https://www.w3.org/ns/activitystreams", "id": "http://other.localhost/9ef8847d72434cff82254f36ff88e710", "type": "Follow", "actor": "http://other.localhost/accounts/other", "object": "http://coscup.localhost/meow/show" } #+end_src #+begin_src python def _process_inbox_request(...): ... actor = feact_actor(ap_object["actor"]) ... if "Follow" == ap_object["type"]: if _handle_follow(actor, ap_object): ... elif "Create" == ap_object["type"]: if _handle_create(ap_object): ... def _handle_follow(...): if ME["id"] != ap_object["object"]: logger.warning("wrong follow object!" + .ap_object["id"]) return False if MANUALLY_APPROVES_FOLLOWERS: save_follow_request(ap_object) return True _send_accept(actor, ap_object) save_follower() return True async def _send_accept(...) -> None : reply_id = BASE_URL + str(uuid.uuid4()) actor_url = actor["inbox"] payload = { "@context": AS_CTX, "id": reply_id, "type": "Accept", "actor": ID, # http://coscup.localhost/user/show "object": ap_object["id"], # http://other.localhost/9ef8847d72434cff82254f36ff88e710 } post(actor_url, payload) #+end_src *** Creat! #+begin_src json { "@context" : [ "https://www.w3.org/ns/activitystreams", { "Emoji" : "toot:Emoji", "atomUri" : "ostatus:atomUri", "conversation" : "ostatus:conversation", "focalPoint" : { "@container" : "@list", "@id" : "toot:focalPoint" }, "inReplyToAtomUri" : "ostatus:inReplyToAtomUri", "ostatus" : "http://ostatus.org#", "sensitive" : "as:sensitive", "toot" : "http://joinmastodon.org/ns#", "votersCount" : "toot:votersCount" } ], "type":"Create", "published" : "2023-07-13T16:04:57Z", "cc" : ["http://coscup.localhost/users/show/followers"], "to" : ["https://www.w3.org/ns/activitystreams#Public"], "id" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f", "object": { "attributedTo" : "http://coscup.localhost/user/show", "cc" : ["http://coscup.localhost/users/show/followers"], "to" : ["https://www.w3.org/ns/activitystreams#Public"], "id" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f", "content" : "

Hello World!

", "inReplyTo" : null, "published" : "2023-07-13T16:04:57Z", "sensitive" : false, "summary" : null, "tag" : [], "type" : "Note", "url" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f" }, } #+end_src #+begin_src python def _send_create(...): object_id = to = [] cc = [] if visibility == "public": to = [ap.AS_PUBLIC] cc = [f"{BASE_URL}/followers"] else: raise ValueError(f"Unsupport visibility {visibility}") ap_object = { "type": "Note", "id": BASE_URL + str(uuid.uuid4()), "attributedTo": ID, "content": content, "to": to, "content": content, #

Hello World!

} ap_object = creat_note(actor, content) inboxes = set() for follower in get_followers(): inboxes.add(follower.sharedInbox or follower.inbox_url) for inbox_url in inboxes: client.post(inbox_url, ap_object) return True def wrap_ap_object(...): return { "@context": AS_EXTENDED_CTX, "actor": config.ID, "to": ap_object.get("to", []), "cc": ap_object.get("cc", []), "id": ap_object["id"], "object": ap_object, "published": now() "type": "Create", } #+end_src #+begin_src json {"@context": "https://www.w3.org/ns/activitystreams", "type": "Create", "id": "https://alice.me/a29a6843-9feb-4c74-a7f7-081b9c9201d3", "to": ["https://www.w3.org/ns/activitystreams#Public"], "actor": "https://alice.me/meow/alice", "object": {"type": "Note", "id": "https://alice.me/49e2d03d-b53a-4c4c-a95c-94a6abf45a19", "attributedTo": "https://alice.me/meow/alice", "to": ["https://www.w3.org/ns/activitystreams#Public"], "content": "Hello world!"}} #+end_src