Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ the retrospective FEP-ae0c relay profiles.
- [x] Add the inbound RFC 9421 verification core with actor/key binding,
required component policy, bounded time validation, and Redis-backed nonce
replay prevention.
- [ ] Wire the inbound core into the ActivityPub decoder with authenticated
actor-key retrieval, legacy/modern profile selection, metrics, and a
real-process signed inbound probe.
- [x] Wire the inbound core into the ActivityPub decoder with authenticated
actor-key retrieval, strict legacy/modern profile selection, bounded
metrics, and a real-process signed inbound/replay/tamper probe.
- [ ] Add explicit outbound `legacy` and `rfc9421` configuration with safe
startup validation and no default change.
- [ ] Design and test destination-aware `dual` negotiation without ambiguous
Expand Down
15 changes: 10 additions & 5 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ var (
// WebfingerResources : Relay's Webfinger Resources
WebfingerResources []models.WebfingerResource

ActorCache *cache.Cache
MachineryServer *machinery.Server
RemoteRequestSigner *httpsignature.Signer
RelayState models.RelayState
OperationalMetrics *observability.Recorder
ActorCache *cache.Cache
MachineryServer *machinery.Server
RemoteRequestSigner *httpsignature.Signer
InboundRFC9421Verifier *httpsignature.RFC9421Verifier
RelayState models.RelayState
OperationalMetrics *observability.Recorder
)

type httpServerBinding struct {
Expand Down Expand Up @@ -147,6 +148,10 @@ func initialize(globalConfig *models.RelayConfig) error {
return err
}
ActorCache = cache.New(5*time.Minute, 10*time.Minute)
InboundRFC9421Verifier, err = newInboundRFC9421Verifier(globalConfig)
if err != nil {
return err
}
Nodeinfo = models.GenerateNodeinfoResources(globalConfig.ServerHostname(), version)
WebfingerResources = append(WebfingerResources, RelayActor.GenerateWebfingerResource(globalConfig.ServerHostname()))

Expand Down
253 changes: 208 additions & 45 deletions api/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,82 +15,220 @@ import (
"github.com/thystra/Activity-Relay/models"
)

func decodeActivity(request *http.Request) (*models.Activity, *models.Actor, []byte, error) {
request.Header.Set("Host", request.Host)
body, err := io.ReadAll(io.LimitReader(request.Body, GlobalConfig.MaxActivityBytes()+1))
const (
inboundSignatureProfileLegacy = "legacy"
inboundSignatureProfileRFC9421 = "rfc9421"
)

func selectInboundSignatureProfile(request *http.Request) string {
if request != nil &&
len(request.Header.Values("Signature-Input")) != 0 {
return inboundSignatureProfileRFC9421
}
return inboundSignatureProfileLegacy
}

func decodeActivity(
request *http.Request,
) (*models.Activity, *models.Actor, []byte, error) {
profile := selectInboundSignatureProfile(request)
body, err := readInboundActivityBody(request)
if err != nil {
return nil, nil, nil, err
}
if int64(len(body)) > GlobalConfig.MaxActivityBytes() {
return nil, nil, nil, errors.New("activity body exceeds configured limit")

var activity *models.Activity
var actor *models.Actor

switch profile {
case inboundSignatureProfileRFC9421:
activity, actor, err = decodeRFC9421Activity(request, body)
default:
activity, actor, err = decodeLegacyActivity(request, body)
}

// Verify HTTPSignature
verifier, err := httpsig.NewVerifier(request)
if err != nil {
recordHTTPSignatureVerification(
profile,
"failure",
classifyHTTPSignatureVerificationError(err),
)
return nil, nil, nil, err
}
KeyID := verifier.KeyId()
keyOwnerActor, err := models.NewActivityPubActorFromRemoteActor(KeyID, fmt.Sprintf("%s (golang net/http; Activity-Relay %s; %s)", GlobalConfig.ServerServiceName(), version, GlobalConfig.ServerHostname().Host), ActorCache, RemoteRequestSigner)

recordHTTPSignatureVerification(profile, "success", "accepted")
return activity, actor, body, nil
}

func readInboundActivityBody(request *http.Request) ([]byte, error) {
if request == nil || request.Body == nil {
return nil, errors.New("activity request body is unavailable")
}
body, err := io.ReadAll(
io.LimitReader(
request.Body,
GlobalConfig.MaxActivityBytes()+1,
),
)
if err != nil {
return nil, nil, nil, err
return nil, err
}
if int64(len(body)) > GlobalConfig.MaxActivityBytes() {
return nil, errors.New(
"activity body exceeds configured limit",
)
}
PubKey, err := models.ReadPublicKeyRSAFromString(keyOwnerActor.PublicKey.PublicKeyPem)
if PubKey == nil {
return nil, nil, nil, errors.New("failed parse PublicKey from string")
return body, nil
}

func parseInboundActivity(body []byte) (*models.Activity, error) {
var activity models.Activity
if err := json.Unmarshal(body, &activity); err != nil {
return nil, err
}
if strings.TrimSpace(activity.Actor) == "" {
return nil, errors.New("activity has no actor")
}
return &activity, nil
}

func decodeRFC9421Activity(
request *http.Request,
body []byte,
) (*models.Activity, *models.Actor, error) {
if InboundRFC9421Verifier == nil {
return nil, nil, errors.New(
"RFC 9421 inbound verifier is unavailable",
)
}

verification, err := InboundRFC9421Verifier.VerifyPOST(
request,
body,
)
if err != nil {
return nil, nil, nil, err
return nil, nil, err
}
err = verifier.Verify(PubKey, httpsig.RSA_SHA256)

activity, err := parseInboundActivity(body)
if err != nil {
return nil, nil, nil, err
return nil, nil, err
}
if err := verification.BindActivityActor(activity.Actor); err != nil {
return nil, nil, err
}

remoteActor, err := models.NewActivityPubActorFromRemoteActor(
activity.Actor,
relayRemoteUserAgent(),
ActorCache,
RemoteRequestSigner,
)
if err != nil {
return nil, nil, err
}
if err := verification.BindActivityActor(remoteActor.ID); err != nil {
return nil, nil, err
}

return activity, &remoteActor, nil
}

func decodeLegacyActivity(
request *http.Request,
body []byte,
) (*models.Activity, *models.Actor, error) {
request.Header.Set("Host", request.Host)

verifier, err := httpsig.NewVerifier(request)
if err != nil {
return nil, nil, err
}
keyID := verifier.KeyId()

keyOwnerActor, err := models.NewActivityPubActorFromRemoteActor(
keyID,
relayRemoteUserAgent(),
ActorCache,
RemoteRequestSigner,
)
if err != nil {
return nil, nil, err
}
publicKey, err := models.ReadPublicKeyRSAFromString(
keyOwnerActor.PublicKey.PublicKeyPem,
)
if err != nil {
return nil, nil, err
}
if publicKey == nil {
return nil, nil, errors.New(
"failed parse PublicKey from string",
)
}
if err := verifier.Verify(publicKey, httpsig.RSA_SHA256); err != nil {
return nil, nil, err
}

// Verify Digest
givenDigest := request.Header.Get("Digest")
hash := sha256.New()
hash.Write(body)
b := hash.Sum(nil)
calculatedDigest := "SHA-256=" + base64.StdEncoding.EncodeToString(b)

_, _ = hash.Write(body)
calculatedDigest := "SHA-256=" +
base64.StdEncoding.EncodeToString(hash.Sum(nil))
if givenDigest != calculatedDigest {
return nil, nil, nil, errors.New("digest header is mismatch")
return nil, nil, errors.New("digest header is mismatch")
}

// Parse Activity
var activity models.Activity
err = json.Unmarshal(body, &activity)
activity, err := parseInboundActivity(body)
if err != nil {
return nil, nil, nil, err
return nil, nil, err
}
if err := verifySignatureActorBinding(KeyID, keyOwnerActor, activity.Actor); err != nil {
return nil, nil, nil, err
if err := verifySignatureActorBinding(
keyID,
keyOwnerActor,
activity.Actor,
); err != nil {
return nil, nil, err
}
remoteActor, err := models.NewActivityPubActorFromRemoteActor(activity.Actor, fmt.Sprintf("%s (golang net/http; Activity-Relay %s; %s)", GlobalConfig.ServerServiceName(), version, GlobalConfig.ServerHostname().Host), ActorCache, RemoteRequestSigner)

remoteActor, err := models.NewActivityPubActorFromRemoteActor(
activity.Actor,
relayRemoteUserAgent(),
ActorCache,
RemoteRequestSigner,
)
if err != nil {
return nil, nil, nil, err
return nil, nil, err
}
if err := verifyActivityActorDocument(activity.Actor, remoteActor); err != nil {
return nil, nil, nil, err
if err := verifyActivityActorDocument(
activity.Actor,
remoteActor,
); err != nil {
return nil, nil, err
}

return &activity, &remoteActor, body, nil
return activity, &remoteActor, nil
}

func normalizedRemoteHost(address string) (string, error) {
parsed, err := url.Parse(address)
if err != nil {
return "", err
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
host := strings.ToLower(
strings.TrimSuffix(parsed.Hostname(), "."),
)
if host == "" {
return "", errors.New("remote URL has no host")
}
return host, nil
}

func verifySignatureActorBinding(keyID string, keyOwner models.Actor, activityActor string) error {
func verifySignatureActorBinding(
keyID string,
keyOwner models.Actor,
activityActor string,
) error {
actorHost, err := normalizedRemoteHost(activityActor)
if err != nil {
return fmt.Errorf("invalid activity actor: %w", err)
Expand All @@ -100,43 +238,68 @@ func verifySignatureActorBinding(keyID string, keyOwner models.Actor, activityAc
return fmt.Errorf("invalid signature key ID: %w", err)
}
if keyHost != actorHost {
return errors.New("signature key host does not match activity actor host")
return errors.New(
"signature key host does not match activity actor host",
)
}
if keyOwner.ID != "" {
ownerHost, err := normalizedRemoteHost(keyOwner.ID)
if err != nil || ownerHost != actorHost {
return errors.New("signature key owner does not match activity actor host")
return errors.New(
"signature key owner does not match activity actor host",
)
}
}
if keyOwner.PublicKey.Owner != "" {
ownerHost, err := normalizedRemoteHost(keyOwner.PublicKey.Owner)
ownerHost, err := normalizedRemoteHost(
keyOwner.PublicKey.Owner,
)
if err != nil || ownerHost != actorHost {
return errors.New("public key owner does not match activity actor host")
return errors.New(
"public key owner does not match activity actor host",
)
}
}
return nil
}

func verifyActivityActorDocument(activityActor string, remoteActor models.Actor) error {
func verifyActivityActorDocument(
activityActor string,
remoteActor models.Actor,
) error {
activityHost, err := normalizedRemoteHost(activityActor)
if err != nil {
return fmt.Errorf("invalid activity actor: %w", err)
}
remoteHost, err := normalizedRemoteHost(remoteActor.ID)
if err != nil || remoteHost != activityHost {
return errors.New("resolved actor document does not match activity actor host")
return errors.New(
"resolved actor document does not match activity actor host",
)
}
return nil
}

func fetchOriginalActivityFromURL(url string) (*models.Activity, *models.Actor, error) {
remoteActivity, err := models.NewActivityPubActivityFromRemoteActivity(url, fmt.Sprintf("%s (golang net/http; Activity-Relay %s; %s)", GlobalConfig.ServerServiceName(), version, GlobalConfig.ServerHostname().Host), RemoteRequestSigner)
func fetchOriginalActivityFromURL(
address string,
) (*models.Activity, *models.Actor, error) {
remoteActivity, err :=
models.NewActivityPubActivityFromRemoteActivity(
address,
relayRemoteUserAgent(),
RemoteRequestSigner,
)
if err != nil {
return nil, nil, err
}
remoteActor, err := models.NewActivityPubActorFromRemoteActor(remoteActivity.Actor, fmt.Sprintf("%s (golang net/http; Activity-Relay %s; %s)", GlobalConfig.ServerServiceName(), version, GlobalConfig.ServerHostname().Host), ActorCache, RemoteRequestSigner)
remoteActor, err := models.NewActivityPubActorFromRemoteActor(
remoteActivity.Actor,
relayRemoteUserAgent(),
ActorCache,
RemoteRequestSigner,
)
if err != nil {
return &remoteActivity, nil, err
}
return &remoteActivity, &remoteActor, err
return &remoteActivity, &remoteActor, nil
}
Loading