User keypairs and HTTP signatures for ActivityPub federation using go-ap (#19133)
* go.mod: add go-fed/{httpsig,activity/pub,activity/streams} dependency go get github.com/go-fed/activity/streams@master go get github.com/go-fed/activity/pub@master go get github.com/go-fed/httpsig@master * activitypub: implement /api/v1/activitypub/user/{username} (#14186) Return informations regarding a Person (as defined in ActivityStreams https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person). Refs: https://github.com/go-gitea/gitea/issues/14186 Signed-off-by: Loïc Dachary <loic@dachary.org> * activitypub: add the public key to Person (#14186) Refs: https://github.com/go-gitea/gitea/issues/14186 Signed-off-by: Loïc Dachary <loic@dachary.org> * activitypub: go-fed conformant Clock instance Signed-off-by: Loïc Dachary <loic@dachary.org> * activitypub: signing http client Signed-off-by: Loïc Dachary <loic@dachary.org> * activitypub: implement the ReqSignature middleware Signed-off-by: Loïc Dachary <loic@dachary.org> * activitypub: hack_16834 Signed-off-by: Loïc Dachary <loic@dachary.org> * Fix CI checks-backend errors with go mod tidy Signed-off-by: Anthony Wang <ta180m@pm.me> * Change 2021 to 2022, properly format package imports Signed-off-by: Anthony Wang <ta180m@pm.me> * Run make fmt and make generate-swagger Signed-off-by: Anthony Wang <ta180m@pm.me> * Use Gitea JSON library, add assert for pkp Signed-off-by: Anthony Wang <ta180m@pm.me> * Run make fmt again, fix err var redeclaration Signed-off-by: Anthony Wang <ta180m@pm.me> * Remove LogSQL from ActivityPub person test Signed-off-by: Anthony Wang <ta180m@pm.me> * Assert if json.Unmarshal succeeds Signed-off-by: Anthony Wang <ta180m@pm.me> * Cleanup, handle invalid usernames for ActivityPub person GET request Signed-off-by: Anthony Wang <ta180m@pm.me> * Rename hack_16834 to user_settings Signed-off-by: Anthony Wang <ta180m@pm.me> * Use the httplib module instead of http for GET requests * Clean up whitespace with make fmt * Use time.RFC1123 and make the http.Client proxy-aware * Check if digest algo is supported in setting module * Clean up some variable declarations * Remove unneeded copy * Use system timezone instead of setting.DefaultUILocation * Use named constant for httpsigExpirationTime * Make pubKey IRI #main-key instead of /#main-key * Move /#main-key to #main-key in tests * Implemented Webfinger endpoint. * Add visible check. * Add user profile as alias. * Add actor IRI and remote interaction URL to WebFinger response * fmt * Fix lint errors * Use go-ap instead of go-fed * Run go mod tidy to fix missing modules in go.mod and go.sum * make fmt * Convert remaining code to go-ap * Clean up go.sum * Fix JSON unmarshall error * Fix CI errors by adding @context to Person() and making sure types match * Correctly decode JSON in api_activitypub_person_test.go * Force CI rerun * Fix TestActivityPubPersonInbox segfault * Fix lint error * Use @mariusor's suggestions for idiomatic go-ap usage * Correctly add inbox/outbox IRIs to person * Code cleanup * Remove another LogSQL from ActivityPub person test * Move httpsig algos slice to an init() function * Add actor IRI and remote interaction URL to WebFinger response * Update TestWebFinger to check for ActivityPub IRI in aliases * make fmt * Force CI rerun * WebFinger: Add CORS header and fix Href -> Template for remote interactions The CORS header is needed due to https://datatracker.ietf.org/doc/html/rfc7033#section-5 and fixes some Peertube <-> Gitea federation issues * make lint-backend * Make sure Person endpoint has Content-Type application/activity+json and includes PreferredUsername, URL, and Icon Setting the correct Content-Type is essential for federating with Mastodon * Use UTC instead of GMT * Rename pkey to pubKey * Make sure HTTP request Date in GMT * make fmt * dont drop err * Make sure API responses always refer to username in original case Copied from what I wrote on #19133 discussion: Handling username case is a very tricky issue and I've already encountered a Mastodon <-> Gitea federation bug due to Gitea considering Ta180m and ta180m to be the same user while Mastodon thinks they are two different users. I think the best way forward is for Gitea to only use the original case version of the username for federation so other AP software don't get confused. * Move httpsig algs constant slice to modules/setting/federation.go * Add new federation settings to app.example.ini and config-cheat-sheet * Return if marshalling error * Make sure Person IRIs are generated correctly This commit ensures that if the setting.AppURL is something like "http://127.0.0.1:42567" (like in the integration tests), a trailing slash will be added after that URL. * If httpsig verification fails, fix Host header and try again This fixes a very rare bug when Gitea and another AP server (confirmed to happen with Mastodon) are running on the same machine, Gitea fails to verify incoming HTTP signatures. This is because the other AP server creates the sig with the public Gitea domain as the Host. However, when Gitea receives the request, the Host header is instead localhost, so the signature verification fails. Manually changing the host header to the correct value and trying the veification again fixes the bug. * Revert "If httpsig verification fails, fix Host header and try again" This reverts commit f53e46c721a037c55facb9200106a6b491bf834c. The bug was actually caused by nginx messing up the Host header when reverse-proxying since I didn't have the line `proxy_set_header Host $host;` in my nginx config for Gitea. * Go back to using ap.IRI to generate inbox and outbox IRIs * use const for key values * Update routers/web/webfinger.go * Use ctx.JSON in Person response to make code cleaner * Revert "Use ctx.JSON in Person response to make code cleaner" This doesn't work because the ctx.JSON() function already sends the response out and it's too late to edit the headers. This reverts commit 95aad988975be3393c76094864ed6ba962157e0c. * Use activitypub.ActivityStreamsContentType for Person response Content Type * Limit maximum ActivityPub request and response sizes to a configurable setting * Move setting key constants to models/user/setting_keys.go * Fix failing ActivityPubPerson integration test by checking the correct field for username * Add a warning about changing settings that can break federation * Add better comments * Don't multiply Federation.MaxSize by 1<<20 twice * Add more better comments * Fix failing ActivityPubMissingPerson test We now use ctx.ContextUser so the message printed out when a user does not exist is slightly different * make generate-swagger For some reason I didn't realize that /templates/swagger/v1_json.tmpl was machine-generated by make generate-swagger... I've been editing it by hand for three months! 🤦 * Move getting the RFC 2616 time to a separate function * More code cleanup * Update go-ap to fix empty liked collection and removed unneeded HTTP headers * go mod tidy * Add ed25519 to httpsig algorithms * Use go-ap/jsonld to add @context and marshal JSON * Change Gitea user agent from the default to Gitea/Version * Use ctx.ServerError and remove all remote interaction code from webfinger.go
This commit is contained in:
parent
909fb6ad20
commit
e86f18a05a
20 changed files with 762 additions and 5 deletions
|
@ -2253,6 +2253,23 @@ PATH =
|
||||||
;;
|
;;
|
||||||
;; Enable/Disable user statistics for nodeinfo if federation is enabled
|
;; Enable/Disable user statistics for nodeinfo if federation is enabled
|
||||||
; SHARE_USER_STATISTICS = true
|
; SHARE_USER_STATISTICS = true
|
||||||
|
;;
|
||||||
|
;; Maximum federation request and response size (MB)
|
||||||
|
; MAX_SIZE = 4
|
||||||
|
;;
|
||||||
|
;; WARNING: Changing the settings below can break federation.
|
||||||
|
;;
|
||||||
|
;; HTTP signature algorithms
|
||||||
|
; ALGORITHMS = rsa-sha256, rsa-sha512, ed25519
|
||||||
|
;;
|
||||||
|
;; HTTP signature digest algorithm
|
||||||
|
; DIGEST_ALGORITHM = SHA-256
|
||||||
|
;;
|
||||||
|
;; GET headers for federation requests
|
||||||
|
; GET_HEADERS = (request-target), Date
|
||||||
|
;;
|
||||||
|
;; POST headers for federation requests
|
||||||
|
; POST_HEADERS = (request-target), Date, Digest
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -1090,6 +1090,14 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
|
||||||
|
|
||||||
- `ENABLED`: **true**: Enable/Disable federation capabilities
|
- `ENABLED`: **true**: Enable/Disable federation capabilities
|
||||||
- `SHARE_USER_STATISTICS`: **true**: Enable/Disable user statistics for nodeinfo if federation is enabled
|
- `SHARE_USER_STATISTICS`: **true**: Enable/Disable user statistics for nodeinfo if federation is enabled
|
||||||
|
- `MAX_SIZE`: **4**: Maximum federation request and response size (MB)
|
||||||
|
|
||||||
|
WARNING: Changing the settings below can break federation.
|
||||||
|
|
||||||
|
- `ALGORITHMS`: **rsa-sha256, rsa-sha512, ed25519**: HTTP signature algorithms
|
||||||
|
- `DIGEST_ALGORITHM`: **SHA-256**: HTTP signature digest algorithm
|
||||||
|
- `GET_HEADERS`: **(request-target), Date**: GET headers for federation requests
|
||||||
|
- `POST_HEADERS`: **(request-target), Date, Digest**: POST headers for federation requests
|
||||||
|
|
||||||
## Packages (`packages`)
|
## Packages (`packages`)
|
||||||
|
|
||||||
|
|
7
go.mod
7
go.mod
|
@ -28,10 +28,12 @@ require (
|
||||||
github.com/ethantkoenig/rupture v1.0.1
|
github.com/ethantkoenig/rupture v1.0.1
|
||||||
github.com/felixge/fgprof v0.9.2
|
github.com/felixge/fgprof v0.9.2
|
||||||
github.com/gliderlabs/ssh v0.3.4
|
github.com/gliderlabs/ssh v0.3.4
|
||||||
|
github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b
|
||||||
|
github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d
|
||||||
github.com/go-chi/chi/v5 v5.0.7
|
github.com/go-chi/chi/v5 v5.0.7
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/go-enry/go-enry/v2 v2.8.2
|
github.com/go-enry/go-enry/v2 v2.8.2
|
||||||
github.com/go-fed/httpsig v1.1.0
|
github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e
|
||||||
github.com/go-git/go-billy/v5 v5.3.1
|
github.com/go-git/go-billy/v5 v5.3.1
|
||||||
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
|
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
|
||||||
github.com/go-ldap/ldap/v3 v3.4.3
|
github.com/go-ldap/ldap/v3 v3.4.3
|
||||||
|
@ -107,6 +109,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.99.0 // indirect
|
cloud.google.com/go v0.99.0 // indirect
|
||||||
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 // indirect
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect
|
||||||
github.com/Microsoft/go-winio v0.5.2 // indirect
|
github.com/Microsoft/go-winio v0.5.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5 // indirect
|
github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5 // indirect
|
||||||
|
@ -160,6 +163,7 @@ require (
|
||||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||||
github.com/fullstorydev/grpcurl v1.8.1 // indirect
|
github.com/fullstorydev/grpcurl v1.8.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
|
||||||
|
github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
||||||
github.com/go-enry/go-oniguruma v1.2.1 // indirect
|
github.com/go-enry/go-oniguruma v1.2.1 // indirect
|
||||||
github.com/go-git/gcfg v1.5.0 // indirect
|
github.com/go-git/gcfg v1.5.0 // indirect
|
||||||
|
@ -252,6 +256,7 @@ require (
|
||||||
github.com/toqueteos/webbrowser v1.2.0 // indirect
|
github.com/toqueteos/webbrowser v1.2.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||||
github.com/unknwon/com v1.0.1 // indirect
|
github.com/unknwon/com v1.0.1 // indirect
|
||||||
|
github.com/valyala/fastjson v1.6.3 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.1 // indirect
|
github.com/xanzy/ssh-agent v0.3.1 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
|
|
14
go.sum
14
go.sum
|
@ -69,6 +69,8 @@ contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EU
|
||||||
contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
|
contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
|
||||||
contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA=
|
contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 h1:2OrsyJYZp7J6nyAsKi2q1SELYRaIc0aQmcQ/EQqPfk8=
|
||||||
|
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
|
||||||
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb h1:Yy0Bxzc8R2wxiwXoG/rECGplJUSpXqCsog9PuJFgiHs=
|
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb h1:Yy0Bxzc8R2wxiwXoG/rECGplJUSpXqCsog9PuJFgiHs=
|
||||||
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb/go.mod h1:77TZu701zMXWJFvB8gvTbQ92zQ3DQq/H7l5wAEjQRKc=
|
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb/go.mod h1:77TZu701zMXWJFvB8gvTbQ92zQ3DQq/H7l5wAEjQRKc=
|
||||||
gitea.com/go-chi/cache v0.0.0-20210110083709-82c4c9ce2d5e/go.mod h1:k2V/gPDEtXGjjMGuBJiapffAXTv76H4snSmlJRLUhH0=
|
gitea.com/go-chi/cache v0.0.0-20210110083709-82c4c9ce2d5e/go.mod h1:k2V/gPDEtXGjjMGuBJiapffAXTv76H4snSmlJRLUhH0=
|
||||||
|
@ -460,6 +462,12 @@ github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0
|
||||||
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
|
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
|
||||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
|
github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b h1:+RjYfEfoZdM3wHFs752dlOpGaoRhwRRyQxjajg08LcQ=
|
||||||
|
github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b/go.mod h1:DE3vvc6Didgfd3k7M1Mos6qMDFNmMrxJmYVMHG9h9Io=
|
||||||
|
github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f h1:kJhGo4NApJP0Lt9lkJnfmuTnRWVFbCynY0kiTxpPUR4=
|
||||||
|
github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f/go.mod h1:KHkKFKZvc05lr79+RGoq/zG8YjWi3+FK60Bxd+mpCew=
|
||||||
|
github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d h1:Z/oRXMlZHjvjIqDma1FrIGL3iE5YL7MUI0bwYEZ6qbA=
|
||||||
|
github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
|
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
@ -472,8 +480,8 @@ github.com/go-enry/go-enry/v2 v2.8.2 h1:uiGmC+3K8sVd/6DOe2AOJEOihJdqda83nPyJNtMR
|
||||||
github.com/go-enry/go-enry/v2 v2.8.2/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
|
github.com/go-enry/go-enry/v2 v2.8.2/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
|
||||||
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
|
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
|
||||||
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
|
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
|
||||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
||||||
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
||||||
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||||
|
@ -1507,6 +1515,8 @@ github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw=
|
||||||
github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||||
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc=
|
||||||
|
github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||||
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
|
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
|
||||||
github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
||||||
|
|
103
integrations/api_activitypub_person_test.go
Normal file
103
integrations/api_activitypub_person_test.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package integrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/activitypub"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
ap "github.com/go-ap/activitypub"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActivityPubPerson(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(*testing.T, *url.URL) {
|
||||||
|
setting.Federation.Enabled = true
|
||||||
|
defer func() {
|
||||||
|
setting.Federation.Enabled = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
username := "user2"
|
||||||
|
req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username))
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
body := resp.Body.Bytes()
|
||||||
|
assert.Contains(t, string(body), "@context")
|
||||||
|
|
||||||
|
var person ap.Person
|
||||||
|
err := person.UnmarshalJSON(body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, ap.PersonType, person.Type)
|
||||||
|
assert.Equal(t, username, person.PreferredUsername.String())
|
||||||
|
keyID := person.GetID().String()
|
||||||
|
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID)
|
||||||
|
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetID().String())
|
||||||
|
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetID().String())
|
||||||
|
|
||||||
|
pubKey := person.PublicKey
|
||||||
|
assert.NotNil(t, pubKey)
|
||||||
|
publicKeyID := keyID + "#main-key"
|
||||||
|
assert.Equal(t, pubKey.ID.String(), publicKeyID)
|
||||||
|
|
||||||
|
pubKeyPem := pubKey.PublicKeyPem
|
||||||
|
assert.NotNil(t, pubKeyPem)
|
||||||
|
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivityPubMissingPerson(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(*testing.T, *url.URL) {
|
||||||
|
setting.Federation.Enabled = true
|
||||||
|
defer func() {
|
||||||
|
setting.Federation.Enabled = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser")
|
||||||
|
resp := MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
assert.Contains(t, resp.Body.String(), "user redirect does not exist")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivityPubPersonInbox(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(c)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
onGiteaRun(t, func(*testing.T, *url.URL) {
|
||||||
|
appURL := setting.AppURL
|
||||||
|
setting.Federation.Enabled = true
|
||||||
|
setting.AppURL = srv.URL
|
||||||
|
defer func() {
|
||||||
|
setting.Federation.Enabled = false
|
||||||
|
setting.Database.LogSQL = false
|
||||||
|
setting.AppURL = appURL
|
||||||
|
}()
|
||||||
|
username1 := "user1"
|
||||||
|
ctx := context.Background()
|
||||||
|
user1, err := user_model.GetUserByName(ctx, username1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
user1url := fmt.Sprintf("%s/api/v1/activitypub/user/%s#main-key", srv.URL, username1)
|
||||||
|
c, err := activitypub.NewClient(user1, user1url)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
username2 := "user2"
|
||||||
|
user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user/%s/inbox", srv.URL, username2)
|
||||||
|
|
||||||
|
// Signed request succeeds
|
||||||
|
resp, err := c.Post([]byte{}, user2inboxurl)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
|
// Unsigned request fails
|
||||||
|
req := NewRequest(t, "POST", user2inboxurl)
|
||||||
|
MakeRequest(t, req, http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
}
|
|
@ -52,7 +52,7 @@ func TestWebfinger(t *testing.T) {
|
||||||
var jrd webfingerJRD
|
var jrd webfingerJRD
|
||||||
DecodeJSON(t, resp, &jrd)
|
DecodeJSON(t, resp, &jrd)
|
||||||
assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject)
|
assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject)
|
||||||
assert.ElementsMatch(t, []string{user.HTMLURL()}, jrd.Aliases)
|
assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(user.Name)}, jrd.Aliases)
|
||||||
|
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
|
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
|
|
@ -9,4 +9,8 @@ const (
|
||||||
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
|
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
|
||||||
// SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff
|
// SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff
|
||||||
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
|
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
|
||||||
|
// UserActivityPubPrivPem is user's private key
|
||||||
|
UserActivityPubPrivPem = "activitypub.priv_pem"
|
||||||
|
// UserActivityPubPubPem is user's public key
|
||||||
|
UserActivityPubPubPem = "activitypub.pub_pem"
|
||||||
)
|
)
|
||||||
|
|
124
modules/activitypub/client.go
Normal file
124
modules/activitypub/client.go
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/proxy"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActivityStreamsContentType const
|
||||||
|
ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
|
||||||
|
httpsigExpirationTime = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gets the current time as an RFC 2616 formatted string
|
||||||
|
// RFC 2616 requires RFC 1123 dates but with GMT instead of UTC
|
||||||
|
func CurrentTime() string {
|
||||||
|
return strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRequiredHTTPHeaders(method string, headers []string) error {
|
||||||
|
var hasRequestTarget, hasDate, hasDigest bool
|
||||||
|
for _, header := range headers {
|
||||||
|
hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
|
||||||
|
hasDate = hasDate || header == "Date"
|
||||||
|
hasDigest = hasDigest || header == "Digest"
|
||||||
|
}
|
||||||
|
if !hasRequestTarget {
|
||||||
|
return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget)
|
||||||
|
} else if !hasDate {
|
||||||
|
return fmt.Errorf("missing http header for %s: Date", method)
|
||||||
|
} else if !hasDigest && method != http.MethodGet {
|
||||||
|
return fmt.Errorf("missing http header for %s: Digest", method)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client struct
|
||||||
|
type Client struct {
|
||||||
|
client *http.Client
|
||||||
|
algs []httpsig.Algorithm
|
||||||
|
digestAlg httpsig.DigestAlgorithm
|
||||||
|
getHeaders []string
|
||||||
|
postHeaders []string
|
||||||
|
priv *rsa.PrivateKey
|
||||||
|
pubID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient function
|
||||||
|
func NewClient(user *user_model.User, pubID string) (c *Client, err error) {
|
||||||
|
if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil {
|
||||||
|
return
|
||||||
|
} else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, err := GetPrivateKey(user)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privPem, _ := pem.Decode([]byte(priv))
|
||||||
|
privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c = &Client{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: proxy.Proxy(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
algs: setting.HttpsigAlgs,
|
||||||
|
digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm),
|
||||||
|
getHeaders: setting.Federation.GetHeaders,
|
||||||
|
postHeaders: setting.Federation.PostHeaders,
|
||||||
|
priv: privParsed,
|
||||||
|
pubID: pubID,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRequest function
|
||||||
|
func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) {
|
||||||
|
buf := bytes.NewBuffer(b)
|
||||||
|
req, err = http.NewRequest(http.MethodPost, to, buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", ActivityStreamsContentType)
|
||||||
|
req.Header.Add("Date", CurrentTime())
|
||||||
|
req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
|
||||||
|
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = signer.SignRequest(c.priv, c.pubID, req, b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post function
|
||||||
|
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
||||||
|
var req *http.Request
|
||||||
|
if req, err = c.NewRequest(b, to); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err = c.client.Do(req)
|
||||||
|
return
|
||||||
|
}
|
49
modules/activitypub/client_test.go
Normal file
49
modules/activitypub/client_test.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
_ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActivityPubSignedPost(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
|
||||||
|
pubID := "https://example.com/pubID"
|
||||||
|
c, err := NewClient(user, pubID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
expected := "BODY"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest"))
|
||||||
|
assert.Contains(t, r.Header.Get("Signature"), pubID)
|
||||||
|
assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, string(body))
|
||||||
|
fmt.Fprintf(w, expected)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r, err := c.Post([]byte(expected), srv.URL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer r.Body.Close()
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, string(body))
|
||||||
|
}
|
18
modules/activitypub/main_test.go
Normal file
18
modules/activitypub/main_test.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
unittest.MainTest(m, &unittest.TestOptions{
|
||||||
|
GiteaRootPath: filepath.Join("..", ".."),
|
||||||
|
})
|
||||||
|
}
|
45
modules/activitypub/user_settings.go
Normal file
45
modules/activitypub/user_settings.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetKeyPair function returns a user's private and public keys
|
||||||
|
func GetKeyPair(user *user_model.User) (pub, priv string, err error) {
|
||||||
|
var settings map[string]*user_model.Setting
|
||||||
|
settings, err = user_model.GetUserSettings(user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
} else if len(settings) == 0 {
|
||||||
|
if priv, pub, err = GenerateKeyPair(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, priv); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, pub); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
priv = settings[user_model.UserActivityPubPrivPem].SettingValue
|
||||||
|
pub = settings[user_model.UserActivityPubPubPem].SettingValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicKey function returns a user's public key
|
||||||
|
func GetPublicKey(user *user_model.User) (pub string, err error) {
|
||||||
|
pub, _, err = GetKeyPair(user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrivateKey function returns a user's private key
|
||||||
|
func GetPrivateKey(user *user_model.User) (priv string, err error) {
|
||||||
|
_, priv, err = GetKeyPair(user)
|
||||||
|
return
|
||||||
|
}
|
29
modules/activitypub/user_settings_test.go
Normal file
29
modules/activitypub/user_settings_test.go
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
|
||||||
|
_ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserSettings(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
|
||||||
|
pub, priv, err := GetKeyPair(user1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pub1, err := GetPublicKey(user1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, pub, pub1)
|
||||||
|
priv1, err := GetPrivateKey(user1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, priv, priv1)
|
||||||
|
}
|
|
@ -4,21 +4,49 @@
|
||||||
|
|
||||||
package setting
|
package setting
|
||||||
|
|
||||||
import "code.gitea.io/gitea/modules/log"
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
|
)
|
||||||
|
|
||||||
// Federation settings
|
// Federation settings
|
||||||
var (
|
var (
|
||||||
Federation = struct {
|
Federation = struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
ShareUserStatistics bool
|
ShareUserStatistics bool
|
||||||
|
MaxSize int64
|
||||||
|
Algorithms []string
|
||||||
|
DigestAlgorithm string
|
||||||
|
GetHeaders []string
|
||||||
|
PostHeaders []string
|
||||||
}{
|
}{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
ShareUserStatistics: true,
|
ShareUserStatistics: true,
|
||||||
|
MaxSize: 4,
|
||||||
|
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
|
||||||
|
DigestAlgorithm: "SHA-256",
|
||||||
|
GetHeaders: []string{"(request-target)", "Date"},
|
||||||
|
PostHeaders: []string{"(request-target)", "Date", "Digest"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Constant slice of httpsig algorithm objects
|
||||||
|
var HttpsigAlgs []httpsig.Algorithm
|
||||||
|
|
||||||
func newFederationService() {
|
func newFederationService() {
|
||||||
if err := Cfg.Section("federation").MapTo(&Federation); err != nil {
|
if err := Cfg.Section("federation").MapTo(&Federation); err != nil {
|
||||||
log.Fatal("Failed to map Federation settings: %v", err)
|
log.Fatal("Failed to map Federation settings: %v", err)
|
||||||
|
} else if !httpsig.IsSupportedDigestAlgorithm(Federation.DigestAlgorithm) {
|
||||||
|
log.Fatal("unsupported digest algorithm: %s", Federation.DigestAlgorithm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get MaxSize in bytes instead of MiB
|
||||||
|
Federation.MaxSize = 1 << 20 * Federation.MaxSize
|
||||||
|
|
||||||
|
HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms))
|
||||||
|
for i, alg := range Federation.Algorithms {
|
||||||
|
HttpsigAlgs[i] = httpsig.Algorithm(alg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
10
modules/structs/activitypub.go
Normal file
10
modules/structs/activitypub.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
// ActivityPub type
|
||||||
|
type ActivityPub struct {
|
||||||
|
Context string `json:"@context"`
|
||||||
|
}
|
106
routers/api/v1/activitypub/person.go
Normal file
106
routers/api/v1/activitypub/person.go
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/activitypub"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
ap "github.com/go-ap/activitypub"
|
||||||
|
"github.com/go-ap/jsonld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Person function returns the Person actor for a user
|
||||||
|
func Person(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson
|
||||||
|
// ---
|
||||||
|
// summary: Returns the Person actor for a user
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/ActivityPub"
|
||||||
|
|
||||||
|
link := strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + ctx.ContextUser.Name
|
||||||
|
person := ap.PersonNew(ap.IRI(link))
|
||||||
|
|
||||||
|
person.Name = ap.NaturalLanguageValuesNew()
|
||||||
|
err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName))
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("Set Name", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
person.PreferredUsername = ap.NaturalLanguageValuesNew()
|
||||||
|
err = person.PreferredUsername.Set("en", ap.Content(ctx.ContextUser.Name))
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("Set PreferredUsername", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
person.URL = ap.IRI(ctx.ContextUser.HTMLURL())
|
||||||
|
|
||||||
|
person.Icon = ap.Image{
|
||||||
|
Type: ap.ImageType,
|
||||||
|
MediaType: "image/png",
|
||||||
|
URL: ap.IRI(ctx.ContextUser.AvatarLink()),
|
||||||
|
}
|
||||||
|
|
||||||
|
person.Inbox = ap.IRI(link + "/inbox")
|
||||||
|
person.Outbox = ap.IRI(link + "/outbox")
|
||||||
|
|
||||||
|
person.PublicKey.ID = ap.IRI(link + "#main-key")
|
||||||
|
person.PublicKey.Owner = ap.IRI(link)
|
||||||
|
|
||||||
|
publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetPublicKey", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
person.PublicKey.PublicKeyPem = publicKeyPem
|
||||||
|
|
||||||
|
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("MarshalJSON", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
if _, err = ctx.Resp.Write(binary); err != nil {
|
||||||
|
log.Error("write to resp err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonInbox function handles the incoming data for a user inbox
|
||||||
|
func PersonInbox(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /activitypub/user/{username}/inbox activitypub activitypubPersonInbox
|
||||||
|
// ---
|
||||||
|
// summary: Send to the inbox
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
102
routers/api/v1/activitypub/reqsignature.go
Normal file
102
routers/api/v1/activitypub/reqsignature.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package activitypub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/activitypub"
|
||||||
|
gitea_context "code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/httplib"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
ap "github.com/go-ap/activitypub"
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
|
||||||
|
person := ap.PersonNew(ap.IRI(keyID.String()))
|
||||||
|
err = person.UnmarshalJSON(b)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubKey := person.PublicKey
|
||||||
|
if pubKey.ID.String() != keyID.String() {
|
||||||
|
err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubKeyPem := pubKey.PublicKeyPem
|
||||||
|
block, _ := pem.Decode([]byte(pubKeyPem))
|
||||||
|
if block == nil || block.Type != "PUBLIC KEY" {
|
||||||
|
err = fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(iri *url.URL) (b []byte, err error) {
|
||||||
|
req := httplib.NewRequest(iri.String(), http.MethodGet)
|
||||||
|
req.Header("Accept", activitypub.ActivityStreamsContentType)
|
||||||
|
req.Header("User-Agent", "Gitea/"+setting.AppVer)
|
||||||
|
resp, err := req.Response()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
|
||||||
|
r := ctx.Req
|
||||||
|
|
||||||
|
// 1. Figure out what key we need to verify
|
||||||
|
v, err := httpsig.NewVerifier(r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ID := v.KeyId()
|
||||||
|
idIRI, err := url.Parse(ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. Fetch the public key of the other actor
|
||||||
|
b, err := fetch(idIRI)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubKey, err := getPublicKeyFromResponse(b, idIRI)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 3. Verify the other actor's key
|
||||||
|
algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
|
||||||
|
authenticated = v.Verify(pubKey, algo) == nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReqHTTPSignature function
|
||||||
|
func ReqHTTPSignature() func(ctx *gitea_context.APIContext) {
|
||||||
|
return func(ctx *gitea_context.APIContext) {
|
||||||
|
if authenticated, err := verifyHTTPSignatures(ctx); err != nil {
|
||||||
|
ctx.ServerError("verifyHttpSignatures", err)
|
||||||
|
} else if !authenticated {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -81,6 +81,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
|
"code.gitea.io/gitea/routers/api/v1/activitypub"
|
||||||
"code.gitea.io/gitea/routers/api/v1/admin"
|
"code.gitea.io/gitea/routers/api/v1/admin"
|
||||||
"code.gitea.io/gitea/routers/api/v1/misc"
|
"code.gitea.io/gitea/routers/api/v1/misc"
|
||||||
"code.gitea.io/gitea/routers/api/v1/notify"
|
"code.gitea.io/gitea/routers/api/v1/notify"
|
||||||
|
@ -643,6 +644,12 @@ func Routes() *web.Route {
|
||||||
m.Get("/version", misc.Version)
|
m.Get("/version", misc.Version)
|
||||||
if setting.Federation.Enabled {
|
if setting.Federation.Enabled {
|
||||||
m.Get("/nodeinfo", misc.NodeInfo)
|
m.Get("/nodeinfo", misc.NodeInfo)
|
||||||
|
m.Group("/activitypub", func() {
|
||||||
|
m.Group("/user/{username}", func() {
|
||||||
|
m.Get("", activitypub.Person)
|
||||||
|
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||||
|
}, context_service.UserAssignmentAPI())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
m.Get("/signing-key.gpg", misc.SigningKey)
|
m.Get("/signing-key.gpg", misc.SigningKey)
|
||||||
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
|
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
|
||||||
|
|
16
routers/api/v1/swagger/activitypub.go
Normal file
16
routers/api/v1/swagger/activitypub.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package swagger
|
||||||
|
|
||||||
|
import (
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivityPub
|
||||||
|
// swagger:response ActivityPub
|
||||||
|
type swaggerResponseActivityPub struct {
|
||||||
|
// in:body
|
||||||
|
Body api.ActivityPub `json:"body"`
|
||||||
|
}
|
|
@ -86,6 +86,7 @@ func WebfingerQuery(ctx *context.Context) {
|
||||||
|
|
||||||
aliases := []string{
|
aliases := []string{
|
||||||
u.HTMLURL(),
|
u.HTMLURL(),
|
||||||
|
appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
|
||||||
}
|
}
|
||||||
if !u.KeepEmailPrivate {
|
if !u.KeepEmailPrivate {
|
||||||
aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email))
|
aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email))
|
||||||
|
@ -101,8 +102,14 @@ func WebfingerQuery(ctx *context.Context) {
|
||||||
Rel: "http://webfinger.net/rel/avatar",
|
Rel: "http://webfinger.net/rel/avatar",
|
||||||
Href: u.AvatarLink(),
|
Href: u.AvatarLink(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Rel: "self",
|
||||||
|
Type: "application/activity+json",
|
||||||
|
Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*")
|
||||||
ctx.JSON(http.StatusOK, &webfingerJRD{
|
ctx.JSON(http.StatusOK, &webfingerJRD{
|
||||||
Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host),
|
Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host),
|
||||||
Aliases: aliases,
|
Aliases: aliases,
|
||||||
|
|
|
@ -23,6 +23,58 @@
|
||||||
},
|
},
|
||||||
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
|
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/activitypub/user/{username}": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"activitypub"
|
||||||
|
],
|
||||||
|
"summary": "Returns the Person actor for a user",
|
||||||
|
"operationId": "activitypubPerson",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "username of the user",
|
||||||
|
"name": "username",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/ActivityPub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/activitypub/user/{username}/inbox": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"activitypub"
|
||||||
|
],
|
||||||
|
"summary": "Send to the inbox",
|
||||||
|
"operationId": "activitypubPersonInbox",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "username of the user",
|
||||||
|
"name": "username",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/cron": {
|
"/admin/cron": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -13113,6 +13165,17 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
},
|
},
|
||||||
|
"ActivityPub": {
|
||||||
|
"description": "ActivityPub type",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"@context": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "Context"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
|
},
|
||||||
"AddCollaboratorOption": {
|
"AddCollaboratorOption": {
|
||||||
"description": "AddCollaboratorOption options when adding a user as a collaborator of a repository",
|
"description": "AddCollaboratorOption options when adding a user as a collaborator of a repository",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -18794,6 +18857,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ActivityPub": {
|
||||||
|
"description": "ActivityPub",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/ActivityPub"
|
||||||
|
}
|
||||||
|
},
|
||||||
"AnnotatedTag": {
|
"AnnotatedTag": {
|
||||||
"description": "AnnotatedTag",
|
"description": "AnnotatedTag",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
Reference in a new issue