From dc338dc881ead40723f0540aac7fe894f58b174d Mon Sep 17 00:00:00 2001
From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date: Sun, 9 May 2021 20:34:27 +0200
Subject: [PATCH] Webfinger + Small fixes (#20)

---
 PROGRESS.md                                |  7 ++-
 internal/api/model/webfinger.go            | 39 +++++++++++++
 internal/api/s2s/webfinger/webfinger.go    | 56 ++++++++++++++++++
 internal/api/s2s/webfinger/webfingerget.go | 68 ++++++++++++++++++++++
 internal/db/pg.go                          |  6 +-
 internal/federation/util.go                |  9 ++-
 internal/gotosocial/actions.go             |  6 ++
 internal/gtsmodel/instance.go              |  2 +-
 internal/message/fediprocess.go            | 30 ++++++++++
 internal/message/instanceprocess.go        |  2 +-
 internal/message/processor.go              |  3 +
 internal/router/router.go                  | 21 +++----
 internal/transport/controller.go           |  4 +-
 internal/typeutils/astointernal.go         | 21 +++----
 internal/typeutils/internaltofrontend.go   |  8 +--
 internal/util/uri.go                       |  4 +-
 16 files changed, 246 insertions(+), 40 deletions(-)
 create mode 100644 internal/api/model/webfinger.go
 create mode 100644 internal/api/s2s/webfinger/webfinger.go
 create mode 100644 internal/api/s2s/webfinger/webfingerget.go

diff --git a/PROGRESS.md b/PROGRESS.md
index 18bcedfa3..20190ef32 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -77,7 +77,7 @@
     * [x] /api/v1/statuses/:id/favourited_by GET            (See who has faved a status)
     * [x] /api/v1/statuses/:id/favourite POST               (Fave a status)
     * [x] /api/v1/statuses/:id/unfavourite POST             (Unfave a status)
-    * [ ] /api/v1/statuses/:id/reblog POST                  (Reblog a status)
+    * [x] /api/v1/statuses/:id/reblog POST                  (Reblog a status)
     * [ ] /api/v1/statuses/:id/unreblog POST                (Undo a reblog)
     * [ ] /api/v1/statuses/:id/bookmark POST                (Bookmark a status)
     * [ ] /api/v1/statuses/:id/unbookmark POST              (Undo a bookmark)
@@ -133,7 +133,7 @@
   * [ ] Search
     * [ ] /api/v2/search GET                                (Get search query results)
   * [ ] Instance
-    * [ ] /api/v1/instance GET                              (Get instance information)
+    * [x] /api/v1/instance GET                              (Get instance information)
     * [ ] /api/v1/instance PATCH                            (Update instance information)
     * [ ] /api/v1/instance/peers GET                        (Get list of federated servers)
     * [ ] /api/v1/instance/activity GET                     (Instance activity over the last 3 months, binned weekly.)
@@ -169,7 +169,8 @@
   * [ ] Oembed
     * [ ] /api/oembed GET                                   (Get oembed metadata for a status URL)
 * [ ] Server-To-Server (Federation protocol)
-  * [ ] Mechanism to trigger side effects from client AP
+  * [x] Mechanism to trigger side effects from client AP
+  * [x] Webfinger account lookups
   * [ ] Federation modes
     * [ ] 'Slow' federation
       * [ ] Reputation scoring system for instances
diff --git a/internal/api/model/webfinger.go b/internal/api/model/webfinger.go
new file mode 100644
index 000000000..bb5008949
--- /dev/null
+++ b/internal/api/model/webfinger.go
@@ -0,0 +1,39 @@
+package model
+
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// WebfingerAccountResponse represents the response to a webfinger request for an 'acct' resource.
+// For example, it would be returned from https://example.org/.well-known/webfinger?resource=acct:some_username@example.org
+//
+// See https://webfinger.net/
+type WebfingerAccountResponse struct {
+	Subject string          `json:"subject"`
+	Aliases []string        `json:"aliases"`
+	Links   []WebfingerLink `json:"links"`
+}
+
+// WebfingerLink represents one 'link' in a slice of webfinger links returned from a lookup request.
+//
+// See https://webfinger.net/
+type WebfingerLink struct {
+	Rel      string `json:"rel"`
+	Type     string `json:"type,omitempty"`
+	Href     string `json:"href,omitempty"`
+	Template string `json:"template,omitempty"`
+}
diff --git a/internal/api/s2s/webfinger/webfinger.go b/internal/api/s2s/webfinger/webfinger.go
new file mode 100644
index 000000000..c11d3fb61
--- /dev/null
+++ b/internal/api/s2s/webfinger/webfinger.go
@@ -0,0 +1,56 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package webfinger
+
+import (
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/message"
+	"github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+	// The base path for serving webfinger lookup requests
+	WebfingerBasePath = ".well-known/webfinger"
+)
+
+// Module implements the FederationModule interface
+type Module struct {
+	config    *config.Config
+	processor message.Processor
+	log       *logrus.Logger
+}
+
+// New returns a new webfinger module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.FederationModule {
+	return &Module{
+		config:    config,
+		processor: processor,
+		log:       log,
+	}
+}
+
+// Route satisfies the FederationModule interface
+func (m *Module) Route(s router.Router) error {
+	s.AttachHandler(http.MethodGet, WebfingerBasePath, m.WebfingerGETRequest)
+	return nil
+}
diff --git a/internal/api/s2s/webfinger/webfingerget.go b/internal/api/s2s/webfinger/webfingerget.go
new file mode 100644
index 000000000..44d60670d
--- /dev/null
+++ b/internal/api/s2s/webfinger/webfingerget.go
@@ -0,0 +1,68 @@
+/*
+   GoToSocial
+   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package webfinger
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+// WebfingerGETRequest handles requests to, for example, https://example.org/.well-known/webfinger?resource=acct:some_user@example.org
+func (m *Module) WebfingerGETRequest(c *gin.Context) {
+
+	q, set := c.GetQuery("resource")
+	if !set || q == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "no 'resource' in request query"})
+		return
+	}
+
+	withAcct := strings.Split(q, "acct:")
+	if len(withAcct) != 2 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	usernameDomain := strings.Split(withAcct[1], "@")
+	if len(usernameDomain) != 2 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+	username := strings.ToLower(usernameDomain[0])
+	domain := strings.ToLower(usernameDomain[1])
+	if username == "" || domain == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+		return
+	}
+
+	if domain != m.config.Host {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("domain %s does not belong to this instance", domain)})
+		return
+	}
+
+	resp, err := m.processor.GetWebfingerAccount(username, c.Request)
+	if err != nil {
+		c.JSON(err.Code(), gin.H{"error": err.Safe()})
+		return
+	}
+
+	c.JSON(http.StatusOK, resp)
+}
diff --git a/internal/db/pg.go b/internal/db/pg.go
index f59103af7..c0fbcc9e0 100644
--- a/internal/db/pg.go
+++ b/internal/db/pg.go
@@ -344,8 +344,8 @@ func (ps *postgresService) CreateInstanceAccount() error {
 func (ps *postgresService) CreateInstanceInstance() error {
 	i := &gtsmodel.Instance{
 		Domain: ps.config.Host,
-		Title: ps.config.Host,
-		URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host),
+		Title:  ps.config.Host,
+		URI:    fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host),
 	}
 	inserted, err := ps.conn.Model(i).Where("domain = ?", ps.config.Host).SelectOrInsert()
 	if err != nil {
@@ -354,7 +354,7 @@ func (ps *postgresService) CreateInstanceInstance() error {
 	if inserted {
 		ps.log.Infof("created instance instance %s with id %s", ps.config.Host, i.ID)
 	} else {
-		ps.log.Infof("instance instance %s already exists with id %s",  ps.config.Host, i.ID)
+		ps.log.Infof("instance instance %s already exists with id %s", ps.config.Host, i.ID)
 	}
 	return nil
 }
diff --git a/internal/federation/util.go b/internal/federation/util.go
index ab854db7c..d76ce853d 100644
--- a/internal/federation/util.go
+++ b/internal/federation/util.go
@@ -112,6 +112,9 @@ func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (voca
 // Also note that this function *does not* dereference the remote account that the signature key is associated with.
 // Other functions should use the returned URL to dereference the remote account, if required.
 func (f *federator) AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) {
+	// set this extra field for signature validation
+	r.Header.Set("host", f.config.Host)
+
 	verifier, err := httpsig.NewVerifier(r)
 	if err != nil {
 		return nil, fmt.Errorf("could not create http sig verifier: %s", err)
@@ -208,7 +211,11 @@ func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *u
 		}
 		return p, nil
 	case string(gtsmodel.ActivityStreamsApplication):
-		// TODO: convert application into person
+		p, ok := t.(vocab.ActivityStreamsApplication)
+		if !ok {
+			return nil, errors.New("error resolving type as activitystreams application")
+		}
+		return p, nil
 	}
 
 	return nil, fmt.Errorf("type name %s not supported", t.GetTypeName())
diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go
index 6d130ed2d..5790456dd 100644
--- a/internal/gotosocial/actions.go
+++ b/internal/gotosocial/actions.go
@@ -37,6 +37,8 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
 	mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
 	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+	"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
+	"github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger"
 	"github.com/superseriousbusiness/gotosocial/internal/api/security"
 	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -109,6 +111,8 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
 	accountModule := account.New(c, processor, log)
 	instanceModule := instance.New(c, processor, log)
 	appsModule := app.New(c, processor, log)
+	webfingerModule := webfinger.New(c, processor, log)
+	usersModule := user.New(c, processor, log)
 	mm := mediaModule.New(c, processor, log)
 	fileServerModule := fileserver.New(c, processor, log)
 	adminModule := admin.New(c, processor, log)
@@ -128,6 +132,8 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
 		fileServerModule,
 		adminModule,
 		statusModule,
+		webfingerModule,
+		usersModule,
 	}
 
 	for _, m := range apis {
diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go
index ac7c990e3..6860627e2 100644
--- a/internal/gtsmodel/instance.go
+++ b/internal/gtsmodel/instance.go
@@ -15,7 +15,7 @@ type Instance struct {
 	// When was this instance created in the db?
 	CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
 	// When was this instance last updated in the db?
-	UpdatedAt   time.Time `pg:"type:timestamp,notnull,default:now()"`
+	UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
 	// When was this instance suspended, if at all?
 	SuspendedAt time.Time
 	// ID of any existing domain block for this instance in the database
diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go
index 6dc6330cf..dad1e848c 100644
--- a/internal/message/fediprocess.go
+++ b/internal/message/fediprocess.go
@@ -5,6 +5,7 @@ import (
 	"net/http"
 
 	"github.com/go-fed/activity/streams"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
@@ -100,3 +101,32 @@ func (p *processor) GetFediUser(requestedUsername string, request *http.Request)
 
 	return data, nil
 }
+
+func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) {
+	// get the account the request is referring to
+	requestedAccount := &gtsmodel.Account{}
+	if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil {
+		return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
+	}
+
+	// return the webfinger representation
+	return &apimodel.WebfingerAccountResponse{
+		Subject: fmt.Sprintf("acct:%s@%s", requestedAccount.Username, p.config.Host),
+		Aliases: []string{
+			requestedAccount.URI,
+			requestedAccount.URL,
+		},
+		Links: []apimodel.WebfingerLink{
+			{
+				Rel:  "http://webfinger.net/rel/profile-page",
+				Type: "text/html",
+				Href: requestedAccount.URL,
+			},
+			{
+				Rel:  "self",
+				Type: "application/activity+json",
+				Href: requestedAccount.URI,
+			},
+		},
+	}, nil
+}
diff --git a/internal/message/instanceprocess.go b/internal/message/instanceprocess.go
index 16a5594de..0b0f15501 100644
--- a/internal/message/instanceprocess.go
+++ b/internal/message/instanceprocess.go
@@ -18,5 +18,5 @@ func (p *processor) InstanceGet(domain string) (*apimodel.Instance, ErrorWithCod
 		return nil, NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err))
 	}
 
-	return  ai, nil
+	return ai, nil
 }
diff --git a/internal/message/processor.go b/internal/message/processor.go
index 0c0334e20..d150d56e6 100644
--- a/internal/message/processor.go
+++ b/internal/message/processor.go
@@ -108,6 +108,9 @@ type Processor interface {
 	// GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
 	// before returning a JSON serializable interface to the caller.
 	GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode)
+
+	// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
+	GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode)
 }
 
 // processor just implements the Processor interface
diff --git a/internal/router/router.go b/internal/router/router.go
index 0f1f288bd..cdd079634 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -25,6 +25,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-contrib/sessions/memstore"
@@ -140,7 +141,13 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) {
 	engine.LoadHTMLGlob(tmPath)
 
 	// create the actual http server here
-	var s *http.Server
+	s := &http.Server{
+		Handler:           engine,
+		ReadTimeout:       1 * time.Second,
+		WriteTimeout:      1 * time.Second,
+		IdleTimeout:       30 * time.Second,
+		ReadHeaderTimeout: 2 * time.Second,
+	}
 	var m *autocert.Manager
 
 	// We need to spawn the underlying server slightly differently depending on whether lets encrypt is enabled or not.
@@ -154,17 +161,11 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) {
 			Email:      config.LetsEncryptConfig.EmailAddress,
 		}
 		// and create an HTTPS server
-		s = &http.Server{
-			Addr:      ":https",
-			TLSConfig: m.TLSConfig(),
-			Handler:   engine,
-		}
+		s.Addr = ":https"
+		s.TLSConfig = m.TLSConfig()
 	} else {
 		// le is NOT enabled, so just serve bare requests on port 8080
-		s = &http.Server{
-			Addr:    ":8080",
-			Handler: engine,
-		}
+		s.Addr = ":8080"
 	}
 
 	return &router{
diff --git a/internal/transport/controller.go b/internal/transport/controller.go
index 525141025..2ee23f141 100644
--- a/internal/transport/controller.go
+++ b/internal/transport/controller.go
@@ -54,8 +54,8 @@ func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient
 func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) {
 	prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
 	digestAlgo := httpsig.DigestSha256
-	getHeaders := []string{"(request-target)", "date"}
-	postHeaders := []string{"(request-target)", "date", "digest"}
+	getHeaders := []string{"(request-target)", "date", "accept"}
+	postHeaders := []string{"(request-target)", "date", "accept", "digest"}
 
 	getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature)
 	if err != nil {
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
index 5e3b6b052..7842411ea 100644
--- a/internal/typeutils/astointernal.go
+++ b/internal/typeutils/astointernal.go
@@ -119,31 +119,26 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode
 	acct.URL = url.String()
 
 	// InboxURI
-	if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil {
-		return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String())
+	if accountable.GetActivityStreamsInbox() != nil || accountable.GetActivityStreamsInbox().GetIRI() != nil {
+		acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String()
 	}
-	acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String()
 
 	// OutboxURI
-	if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil {
-		return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String())
+	if accountable.GetActivityStreamsOutbox() != nil && accountable.GetActivityStreamsOutbox().GetIRI() != nil {
+		acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String()
 	}
-	acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String()
 
 	// FollowingURI
-	if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil {
-		return nil, fmt.Errorf("person with id %s had no following uri", uri.String())
+	if accountable.GetActivityStreamsFollowing() != nil && accountable.GetActivityStreamsFollowing().GetIRI() != nil {
+		acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String()
 	}
-	acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String()
 
 	// FollowersURI
-	if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil {
-		return nil, fmt.Errorf("person with id %s had no followers uri", uri.String())
+	if accountable.GetActivityStreamsFollowers() != nil && accountable.GetActivityStreamsFollowers().GetIRI() != nil {
+		acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String()
 	}
-	acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String()
 
 	// FeaturedURI
-	// very much optional
 	if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil {
 		acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String()
 	}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 6b0c743ff..861350b44 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -554,11 +554,11 @@ func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility {
 
 func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, error) {
 	mi := &model.Instance{
-		URI: i.URI,
-		Title: i.Title,
-		Description: i.Description,
+		URI:              i.URI,
+		Title:            i.Title,
+		Description:      i.Description,
 		ShortDescription: i.ShortDescription,
-		Email: i.ContactEmail,
+		Email:            i.ContactEmail,
 	}
 
 	if i.Domain == c.config.Host {
diff --git a/internal/util/uri.go b/internal/util/uri.go
index 9b96edc61..538df9210 100644
--- a/internal/util/uri.go
+++ b/internal/util/uri.go
@@ -46,7 +46,7 @@ const (
 	// FeaturedPath represents the webfinger featured location
 	FeaturedPath = "featured"
 	// PublicKeyPath is for serving an account's public key
-	PublicKeyPath = "publickey"
+	PublicKeyPath = "main-key"
 )
 
 // APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains
@@ -113,7 +113,7 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User
 	followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath)
 	likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath)
 	collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath)
-	publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath)
+	publicKeyURI := fmt.Sprintf("%s#%s", userURI, PublicKeyPath)
 
 	return &UserURIs{
 		HostURL:     hostURL,