diff --git a/TODO.md b/TODO.md index 19d54cb..c60b560 100644 --- a/TODO.md +++ b/TODO.md @@ -74,8 +74,8 @@ the retrospective FEP-ae0c relay profiles. - [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. +- [x] Add explicit outbound `legacy` and `rfc9421` configuration with safe + startup validation, shared server/worker wiring, and no default change. - [ ] Design and test destination-aware `dual` negotiation without ambiguous simultaneous `Signature` fields or blind duplicate POST delivery. - [ ] Run mixed-profile Mastodon, Friendica, NodeBB, WordPress, and two-relay diff --git a/api/api.go b/api/api.go index cfd7149..514be67 100644 --- a/api/api.go +++ b/api/api.go @@ -27,7 +27,7 @@ var ( ActorCache *cache.Cache MachineryServer *machinery.Server - RemoteRequestSigner *httpsignature.Signer + RemoteRequestSigner *httpsignature.ConfiguredSigner InboundRFC9421Verifier *httpsignature.RFC9421Verifier RelayState models.RelayState OperationalMetrics *observability.Recorder @@ -140,9 +140,10 @@ func initialize(globalConfig *models.RelayConfig) error { } RelayActor = models.NewActivityPubActorFromRelayConfig(globalConfig) - RemoteRequestSigner, err = httpsignature.NewSigner( + RemoteRequestSigner, err = httpsignature.NewConfiguredSigner( RelayActor.PublicKey.ID, globalConfig.ActorKey(), + globalConfig.OutboundSignatureProfile(), ) if err != nil { return err diff --git a/api/outbound_profile_test.go b/api/outbound_profile_test.go new file mode 100644 index 0000000..385ef65 --- /dev/null +++ b/api/outbound_profile_test.go @@ -0,0 +1,13 @@ +package api + +import "testing" + +func TestAPIConfiguredOutboundSignerMatchesRelayConfig(t *testing.T) { + if RemoteRequestSigner == nil { + t.Fatal("API outbound request signer is nil") + } + if got, want := RemoteRequestSigner.Profile(), + GlobalConfig.OutboundSignatureProfile(); got != want { + t.Fatalf("API outbound profile = %q; want %q", got, want) + } +} diff --git a/config.yml.example b/config.yml.example index e55536e..6295348 100644 --- a/config.yml.example +++ b/config.yml.example @@ -11,6 +11,10 @@ JOB_CONCURRENCY: 50 MAX_ACTIVITY_BYTES: 1048576 MAX_FANOUT_TARGETS: 5000 MAX_QUEUE_JOBS: 100000 +# Outbound authorized-fetch GET and delivery POST signature profile. +# Supported values: legacy and rfc9421. Empty or omitted preserves legacy. +# dual is intentionally rejected until destination-aware negotiation exists. +OUTBOUND_SIGNATURE_PROFILE: legacy # Single-disk defaults used by contrib/ops/resource-guard.py. These should # match RELAY_STORAGE_DIR and RELAY_CACHE_DIR in the deployment environment. diff --git a/deliver/deliver.go b/deliver/deliver.go index c2d461c..fc45cfe 100644 --- a/deliver/deliver.go +++ b/deliver/deliver.go @@ -16,6 +16,7 @@ import ( "github.com/sirupsen/logrus" "github.com/thystra/Activity-Relay/internal/deliverymeta" "github.com/thystra/Activity-Relay/internal/deliverypolicy" + "github.com/thystra/Activity-Relay/internal/httpsignature" "github.com/thystra/Activity-Relay/internal/observability" "github.com/thystra/Activity-Relay/models" ) @@ -27,10 +28,11 @@ var ( // RelayActor : Relay's Actor RelayActor models.Actor - HttpClient *http.Client - MachineryServer *machinery.Server - RedisClient *redis.Client - OperationalMetrics *observability.Recorder + HttpClient *http.Client + MachineryServer *machinery.Server + RedisClient *redis.Client + OperationalMetrics *observability.Recorder + OutboundRequestSigner *httpsignature.ConfiguredSigner ) func recordDeliveryMetric(result, errorClass string) { @@ -356,6 +358,14 @@ func initialize(globalConfig *models.RelayConfig) error { HttpClient = &http.Client{Timeout: time.Duration(5) * time.Second} RelayActor = models.NewActivityPubActorFromRelayConfig(globalConfig) + OutboundRequestSigner, err = httpsignature.NewConfiguredSigner( + RelayActor.PublicKey.ID, + globalConfig.ActorKey(), + globalConfig.OutboundSignatureProfile(), + ) + if err != nil { + return err + } newNullLogger := NewNullLogger() log.DEBUG = newNullLogger diff --git a/deliver/outbound_profile_test.go b/deliver/outbound_profile_test.go new file mode 100644 index 0000000..2178160 --- /dev/null +++ b/deliver/outbound_profile_test.go @@ -0,0 +1,13 @@ +package deliver + +import "testing" + +func TestWorkerConfiguredOutboundSignerMatchesRelayConfig(t *testing.T) { + if OutboundRequestSigner == nil { + t.Fatal("worker outbound request signer is nil") + } + if got, want := OutboundRequestSigner.Profile(), + GlobalConfig.OutboundSignatureProfile(); got != want { + t.Fatalf("worker outbound profile = %q; want %q", got, want) + } +} diff --git a/deliver/sender.go b/deliver/sender.go index 5d423b1..3fdbf1c 100644 --- a/deliver/sender.go +++ b/deliver/sender.go @@ -19,7 +19,19 @@ import ( const maxDeliveryResponseBodyBytes int64 = 4096 func appendSignature(request *http.Request, body *[]byte, KeyID string, privateKey *rsa.PrivateKey) error { - signer, err := httpsignature.NewSigner(KeyID, privateKey) + if OutboundRequestSigner != nil { + return OutboundRequestSigner.SignPOST(request, *body) + } + + profile := httpsignature.ProfileLegacy + if GlobalConfig != nil { + profile = GlobalConfig.OutboundSignatureProfile() + } + signer, err := httpsignature.NewConfiguredSigner( + KeyID, + privateKey, + profile, + ) if err != nil { return err } diff --git a/deliver/sender_test.go b/deliver/sender_test.go index 898a7a9..2d751dc 100644 --- a/deliver/sender_test.go +++ b/deliver/sender_test.go @@ -2,6 +2,7 @@ package deliver import ( "bytes" + "context" "crypto/sha256" "encoding/base64" "io" @@ -12,7 +13,12 @@ import ( "time" "github.com/Songmu/go-httpdate" + "github.com/common-fate/httpsig/alg_rsa" + "github.com/common-fate/httpsig/contentdigest" + "github.com/common-fate/httpsig/sigbase" + "github.com/common-fate/httpsig/sigset" "github.com/go-fed/httpsig" + relayhttpsig "github.com/thystra/Activity-Relay/internal/httpsignature" ) func TestAppendSignature(t *testing.T) { @@ -21,7 +27,10 @@ func TestAppendSignature(t *testing.T) { req, _ := http.NewRequest("POST", "https://localhost", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/activity+json") req.Header.Set("Date", httpdate.Time2Str(time.Now())) - appendSignature(req, &body, "https://toot.yukimochi.jp/users/YUKIMOCHI#main-key", GlobalConfig.ActorKey()) + appendSignature(req, &body, RelayActor.PublicKey.ID, GlobalConfig.ActorKey()) + if req.Header.Get("Signature-Input") != "" { + t.Fatal("legacy delivery unexpectedly emitted Signature-Input") + } // Activated compatibilityForHTTPSignature11 sign := req.Header.Get("Signature") @@ -51,3 +60,86 @@ func TestAppendSignature(t *testing.T) { t.Fatalf("Expected Digest header to be '%s', but got '%s'", calculatedDigest, givenDigest) } } + +func verifyRFC9421Delivery( + request *http.Request, +) error { + set, err := sigset.Unmarshal(request) + if err != nil { + return err + } + message, err := set.Find("activitypub") + if err != nil { + return err + } + base, err := sigbase.Derive( + message.Input, + nil, + request, + contentdigest.SHA256, + ) + if err != nil { + return err + } + signingString, err := base.CanonicalString(message.Input) + if err != nil { + return err + } + return alg_rsa.NewRSAPKCS256Verifier( + &GlobalConfig.ActorKey().PublicKey, + ).Verify( + context.Background(), + signingString, + message.Signature, + ) +} + +func TestAppendSignatureRFC9421(t *testing.T) { + previous := OutboundRequestSigner + t.Cleanup(func() { + OutboundRequestSigner = previous + }) + + var err error + OutboundRequestSigner, err = relayhttpsig.NewConfiguredSigner( + RelayActor.PublicKey.ID, + GlobalConfig.ActorKey(), + relayhttpsig.ProfileRFC9421, + ) + if err != nil { + t.Fatal(err) + } + + body := []byte(`{"type":"Announce"}`) + request, err := http.NewRequest( + http.MethodPost, + "https://remote.example:8443/inbox", + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/activity+json") + request.Header.Set("Date", httpdate.Time2Str(time.Now())) + + if err := appendSignature( + request, + &body, + RelayActor.PublicKey.ID, + GlobalConfig.ActorKey(), + ); err != nil { + t.Fatal(err) + } + if request.Header.Get("Signature-Input") == "" { + t.Fatal("RFC 9421 delivery has no Signature-Input") + } + if request.Header.Get("Content-Digest") == "" { + t.Fatal("RFC 9421 delivery has no Content-Digest") + } + if request.Header.Get("Digest") != "" { + t.Fatal("RFC 9421 delivery retained legacy Digest") + } + if err := verifyRFC9421Delivery(request); err != nil { + t.Fatalf("verify RFC 9421 delivery: %v", err) + } +} diff --git a/docs/HTTP-MESSAGE-SIGNATURES.md b/docs/HTTP-MESSAGE-SIGNATURES.md index edd7aaa..23bd83b 100644 --- a/docs/HTTP-MESSAGE-SIGNATURES.md +++ b/docs/HTTP-MESSAGE-SIGNATURES.md @@ -62,16 +62,13 @@ material before signing. GET requests do not carry a content digest. ## Security and rollout gates -The standards core is not runtime-selectable yet. The next tranches must add: +Inbound RFC 9421 verification and explicit outbound profile selection are +runtime-selectable. Remaining rollout gates are: -1. inbound RFC 9421 verification with actor/key binding, required components, - bounded `created` validation, and Redis-backed nonce replay prevention; -2. isolated receiver and sender integration tests; -3. destination capability state and an explicitly reviewed dual negotiation +1. destination capability state and an explicitly reviewed dual negotiation policy; -4. metrics that distinguish legacy, modern, fallback, verification, and replay - outcomes; and -5. mixed-version interoperability tests before changing the default. +2. outbound profile metrics and negotiation outcomes where applicable; and +3. mixed-version interoperability tests before changing the default. Linked Data proofs remain separate from HTTP request authentication. @@ -134,8 +131,8 @@ normalization. The activity actor document fetched for normal relay processing is checked through the same exact binding. The verifier is initialized from the relay's configured public HTTPS authority -and existing Redis client. This is additive inbound support; outbound delivery -and authorized fetch still use the legacy profile. +and existing Redis client. Inbound support is additive and independent of the +operator-selected outbound profile. ## Inbound signature metrics @@ -150,3 +147,27 @@ and `rfc9421`. Results are `success` and `failure`. Reasons are bounded to accepted, parse, key, crypto, digest, replay, time, actor, authority, policy, Redis, activity, or other. Raw actor IDs, key IDs, nonce values, URLs, and error text are never metric labels. + +## Outbound runtime profile + +`OUTBOUND_SIGNATURE_PROFILE` controls both relay-authenticated remote GETs and +worker delivery POSTs. + +Accepted values are: + +- `legacy`: the established Fediverse `Signature` and `Digest` profile; and +- `rfc9421`: RFC 9421 `Signature-Input` and `Signature`, with RFC 9530 + `Content-Digest` on POST requests. + +An empty or omitted value is normalized to `legacy`. This preserves existing +installations and generated test configurations. Unknown values fail startup. +`dual` also fails startup because it is a destination-aware policy rather than +one safe request format. + +The API server and every worker construct the same configured signer from the +same validated `RelayConfig`. Authorized-fetch redirects remove all legacy and +modern signature fields before re-signing the new request target. + +Selecting `rfc9421` is an explicit operator action. It does not probe a +destination, retry a POST with another signature grammar, or fall back after a +remote rejection. diff --git a/docs/INTEGRATION-TESTING.md b/docs/INTEGRATION-TESTING.md index f48ff3a..1fee3bb 100644 --- a/docs/INTEGRATION-TESTING.md +++ b/docs/INTEGRATION-TESTING.md @@ -270,3 +270,34 @@ It requires: The evidence directory contains the exact relay configuration, process log, binary checksum, and machine-readable report. + +## Outbound signature profile configuration + +Focused validation must cover both values: + +```text +go test -count=1 ./internal/httpsignature ./models ./api ./deliver +``` + +The configuration and runtime suite requires: + +- omitted and empty configuration to resolve to `legacy`; +- case-normalized `rfc9421` to be accepted; +- unknown and `dual` values to fail configuration construction; +- API and worker configured signers to match `RelayConfig`; +- legacy authorized-fetch GETs and delivery POSTs to retain their established + fields and cryptographic verification; +- RFC 9421 GETs and POSTs to verify cryptographically; +- RFC 9421 POSTs to use `Content-Digest` and omit legacy `Digest`; +- redirects to remove stale fields and re-sign the redirected target; and +- no automatic fallback or duplicate POST attempt. + +Because omitted configuration is the compatibility default, also rerun: + +```text +contrib/ops/test_rfc9421_inbound_probe.sh /absolute/private/evidence/path +contrib/ops/test_fep_ae0c_two_relay_probe.sh /absolute/private/evidence/path +``` + +The first preserves the modern-inbound/legacy-outbound boundary. The second +must retain `no_reflection_observed` with legacy wire signatures. diff --git a/internal/httpsignature/configured_signer.go b/internal/httpsignature/configured_signer.go new file mode 100644 index 0000000..137be11 --- /dev/null +++ b/internal/httpsignature/configured_signer.go @@ -0,0 +1,75 @@ +package httpsignature + +import ( + "crypto/rsa" + "fmt" + "net/http" +) + +// ParseOutboundProfile validates one operator-selected outbound profile. +// Empty input preserves the established legacy behavior. Dual remains a +// destination negotiation policy and is rejected as a process-wide wire mode. +func ParseOutboundProfile(value string) (Profile, error) { + profile, err := ParseProfile(value) + if err != nil { + return "", err + } + if profile == ProfileDual { + return "", ErrDualProfileRequiresDeliveryPolicy + } + return profile, nil +} + +// ConfiguredSigner applies one validated outbound profile to authorized-fetch +// GETs and ActivityPub delivery POSTs. +type ConfiguredSigner struct { + signer *Signer + profile Profile +} + +// NewConfiguredSigner creates a signer for one process-wide outbound profile. +func NewConfiguredSigner( + keyID string, + privateKey *rsa.PrivateKey, + profile Profile, +) (*ConfiguredSigner, error) { + normalized, err := ParseOutboundProfile(profile.String()) + if err != nil { + return nil, fmt.Errorf("outbound HTTP signature profile: %w", err) + } + signer, err := NewSigner(keyID, privateKey) + if err != nil { + return nil, err + } + return &ConfiguredSigner{ + signer: signer, + profile: normalized, + }, nil +} + +// Profile returns the normalized configured profile. +func (signer *ConfiguredSigner) Profile() Profile { + if signer == nil { + return "" + } + return signer.profile +} + +// SignGET signs one authorized-fetch request using the configured profile. +func (signer *ConfiguredSigner) SignGET(request *http.Request) error { + if signer == nil || signer.signer == nil { + return fmt.Errorf("configured outbound HTTP signer is not initialized") + } + return signer.signer.SignGETWithProfile(request, signer.profile) +} + +// SignPOST signs one delivery request using the configured profile. +func (signer *ConfiguredSigner) SignPOST( + request *http.Request, + body []byte, +) error { + if signer == nil || signer.signer == nil { + return fmt.Errorf("configured outbound HTTP signer is not initialized") + } + return signer.signer.SignPOSTWithProfile(request, body, signer.profile) +} diff --git a/internal/httpsignature/configured_signer_test.go b/internal/httpsignature/configured_signer_test.go new file mode 100644 index 0000000..ac17a67 --- /dev/null +++ b/internal/httpsignature/configured_signer_test.go @@ -0,0 +1,147 @@ +package httpsignature + +import ( + "errors" + "net/http" + "testing" +) + +func TestParseOutboundProfile(t *testing.T) { + tests := []struct { + name string + input string + want Profile + wantError error + }{ + { + name: "empty defaults to legacy", + want: ProfileLegacy, + }, + { + name: "legacy", + input: "legacy", + want: ProfileLegacy, + }, + { + name: "RFC 9421 normalized", + input: " RFC9421 ", + want: ProfileRFC9421, + }, + { + name: "dual rejected", + input: "dual", + wantError: ErrDualProfileRequiresDeliveryPolicy, + }, + { + name: "unknown rejected", + input: "automatic", + wantError: errors.New("unsupported"), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ParseOutboundProfile(test.input) + if test.wantError != nil { + if err == nil { + t.Fatal("expected outbound profile error") + } + if errors.Is( + test.wantError, + ErrDualProfileRequiresDeliveryPolicy, + ) && !errors.Is( + err, + ErrDualProfileRequiresDeliveryPolicy, + ) { + t.Fatalf("outbound profile error = %v", err) + } + return + } + if err != nil { + t.Fatalf("parse outbound profile: %v", err) + } + if got != test.want { + t.Fatalf("profile = %q; want %q", got, test.want) + } + }) + } +} + +func TestConfiguredSignerPreservesLegacyDefault(t *testing.T) { + baseSigner, publicKey := newTestSigner(t) + signer, err := NewConfiguredSigner( + baseSigner.keyID, + baseSigner.privateKey, + "", + ) + if err != nil { + t.Fatal(err) + } + if signer.Profile() != ProfileLegacy { + t.Fatalf("profile = %q; want legacy", signer.Profile()) + } + + body := []byte(`{"type":"Follow"}`) + request := newSignedProfileTestRequest(t, http.MethodPost, body) + if err := signer.SignPOST(request, body); err != nil { + t.Fatal(err) + } + if request.Header.Get("Digest") == "" { + t.Fatal("legacy configured signer has no Digest field") + } + if request.Header.Get("Signature-Input") != "" { + t.Fatal("legacy configured signer emitted Signature-Input") + } + if err := verifyRequestError(request, publicKey); err != nil { + t.Fatalf("verify legacy configured signer: %v", err) + } +} + +func TestConfiguredSignerUsesRFC9421ForGETAndPOST(t *testing.T) { + baseSigner, publicKey := newTestSigner(t) + signer, err := NewConfiguredSigner( + baseSigner.keyID, + baseSigner.privateKey, + ProfileRFC9421, + ) + if err != nil { + t.Fatal(err) + } + + getRequest := newSignedProfileTestRequest(t, http.MethodGet, nil) + if err := signer.SignGET(getRequest); err != nil { + t.Fatal(err) + } + if getRequest.Header.Get("Signature-Input") == "" { + t.Fatal("RFC 9421 configured GET has no Signature-Input") + } + if err := verifyRFC9421Request(getRequest, publicKey); err != nil { + t.Fatalf("verify configured RFC 9421 GET: %v", err) + } + + body := []byte(`{"type":"Follow"}`) + postRequest := newSignedProfileTestRequest(t, http.MethodPost, body) + if err := signer.SignPOST(postRequest, body); err != nil { + t.Fatal(err) + } + if postRequest.Header.Get("Content-Digest") == "" { + t.Fatal("RFC 9421 configured POST has no Content-Digest") + } + if postRequest.Header.Get("Digest") != "" { + t.Fatal("RFC 9421 configured POST retained legacy Digest") + } + if err := verifyRFC9421Request(postRequest, publicKey); err != nil { + t.Fatalf("verify configured RFC 9421 POST: %v", err) + } +} + +func TestConfiguredSignerRejectsDual(t *testing.T) { + baseSigner, _ := newTestSigner(t) + _, err := NewConfiguredSigner( + baseSigner.keyID, + baseSigner.privateKey, + ProfileDual, + ) + if !errors.Is(err, ErrDualProfileRequiresDeliveryPolicy) { + t.Fatalf("dual configured signer error = %v", err) + } +} diff --git a/main.go b/main.go index 112a113..d94ac01 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ YAML Format RELAY_DOMAIN: relay.example.org RELAY_SERVICENAME: Example ActivityPub Relay JOB_CONCURRENCY: 50 + OUTBOUND_SIGNATURE_PROFILE: legacy RELAY_SUMMARY: | Example ActivityPub Relay is powered by Activity-Relay RELAY_ICON: https://example.com/example_icon.png @@ -41,6 +42,7 @@ This is Optional : When config file not exist, use environment variables. - RELAY_DOMAIN - RELAY_SERVICENAME - JOB_CONCURRENCY + - OUTBOUND_SIGNATURE_PROFILE - RELAY_SUMMARY - RELAY_ICON - RELAY_IMAGE @@ -177,6 +179,7 @@ func initConfig(cmd *cobra.Command) { viper.BindEnv("MAX_ACTIVITY_BYTES") viper.BindEnv("MAX_FANOUT_TARGETS") viper.BindEnv("MAX_QUEUE_JOBS") + viper.BindEnv("OUTBOUND_SIGNATURE_PROFILE") } GlobalConfig, err = models.NewRelayConfig() diff --git a/models/config.go b/models/config.go index db1113d..c9d626d 100644 --- a/models/config.go +++ b/models/config.go @@ -19,30 +19,40 @@ import ( "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "github.com/spf13/viper" + relayhttpsig "github.com/thystra/Activity-Relay/internal/httpsignature" ) // RelayConfig contains valid configuration. type RelayConfig struct { - actorKey *rsa.PrivateKey - domain *url.URL - redisClient *redis.Client - redisOptions *redis.Options - redisURL string - redisDisplayURL string - serverBind string - observabilityBind string - serviceName string - serviceSummary string - serviceIconURL *url.URL - serviceImageURL *url.URL - jobConcurrency int - maxActivityBytes int64 - maxFanoutTargets int - maxQueueJobs int64 + actorKey *rsa.PrivateKey + domain *url.URL + redisClient *redis.Client + redisOptions *redis.Options + redisURL string + redisDisplayURL string + serverBind string + observabilityBind string + serviceName string + serviceSummary string + serviceIconURL *url.URL + serviceImageURL *url.URL + jobConcurrency int + maxActivityBytes int64 + maxFanoutTargets int + maxQueueJobs int64 + outboundSignatureProfile relayhttpsig.Profile } // NewRelayConfig create valid RelayConfig from viper configuration. func NewRelayConfig() (*RelayConfig, error) { + outboundSignatureProfile, err := relayhttpsig.ParseOutboundProfile( + viper.GetString("OUTBOUND_SIGNATURE_PROFILE"), + ) + if err != nil { + return nil, errors.New( + "OUTBOUND_SIGNATURE_PROFILE: " + err.Error(), + ) + } domain, err := url.ParseRequestURI("https://" + viper.GetString("RELAY_DOMAIN")) if err != nil { return nil, errors.New("RELAY_DOMAIN: " + err.Error()) @@ -113,22 +123,23 @@ func NewRelayConfig() (*RelayConfig, error) { } return &RelayConfig{ - actorKey: privateKey, - domain: domain, - redisClient: redisClient, - redisOptions: redisOptions, - redisURL: redisURL, - redisDisplayURL: redactedRedisURL(redisURL), - serverBind: serverBind, - observabilityBind: observabilityBind, - serviceName: viper.GetString("RELAY_SERVICENAME"), - serviceSummary: viper.GetString("RELAY_SUMMARY"), - serviceIconURL: iconURL, - serviceImageURL: imageURL, - jobConcurrency: jobConcurrency, - maxActivityBytes: maxActivityBytes, - maxFanoutTargets: maxFanoutTargets, - maxQueueJobs: maxQueueJobs, + actorKey: privateKey, + domain: domain, + redisClient: redisClient, + redisOptions: redisOptions, + redisURL: redisURL, + redisDisplayURL: redactedRedisURL(redisURL), + serverBind: serverBind, + observabilityBind: observabilityBind, + serviceName: viper.GetString("RELAY_SERVICENAME"), + serviceSummary: viper.GetString("RELAY_SUMMARY"), + serviceIconURL: iconURL, + serviceImageURL: imageURL, + jobConcurrency: jobConcurrency, + maxActivityBytes: maxActivityBytes, + maxFanoutTargets: maxFanoutTargets, + maxQueueJobs: maxQueueJobs, + outboundSignatureProfile: outboundSignatureProfile, }, nil } @@ -166,6 +177,15 @@ func (relayConfig *RelayConfig) MaxFanoutTargets() int { return relayConfig.maxF // MaxQueueJobs limits admission based on the Redis broker backlog. func (relayConfig *RelayConfig) MaxQueueJobs() int64 { return relayConfig.maxQueueJobs } +// OutboundSignatureProfile is the process-wide authorized-fetch and +// delivery HTTP-signature profile. +func (relayConfig *RelayConfig) OutboundSignatureProfile() relayhttpsig.Profile { + if relayConfig == nil || relayConfig.outboundSignatureProfile == "" { + return relayhttpsig.ProfileLegacy + } + return relayConfig.outboundSignatureProfile +} + // ActorKey is API Worker's HTTPSignature private key. func (relayConfig *RelayConfig) ActorKey() *rsa.PrivateKey { return relayConfig.actorKey @@ -189,8 +209,9 @@ RELAY DOMAIN : %s REDIS URL : %s BIND ADDRESS : %s OBSERVABILITY : %s +SIGNATURE PROFILE: %s JOB_CONCURRENCY : %s -`, version, moduleName, relayConfig.serviceName, relayConfig.domain.Host, relayConfig.redisDisplayURL, relayConfig.serverBind, observabilityBind, strconv.Itoa(relayConfig.jobConcurrency)) +`, version, moduleName, relayConfig.serviceName, relayConfig.domain.Host, relayConfig.redisDisplayURL, relayConfig.serverBind, observabilityBind, relayConfig.OutboundSignatureProfile(), strconv.Itoa(relayConfig.jobConcurrency)) } func redactedRedisURL(redisURL string) string { diff --git a/models/config_test.go b/models/config_test.go index 4dc036f..77b57af 100644 --- a/models/config_test.go +++ b/models/config_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/spf13/viper" + relayhttpsig "github.com/thystra/Activity-Relay/internal/httpsignature" ) func TestNewRelayConfig(t *testing.T) { @@ -36,16 +37,24 @@ func TestNewRelayConfig(t *testing.T) { if relayConfig.serviceImageURL.String() != "https://example.com/example_image.png" { t.Errorf("Expected RelayConfig.serviceImageURL to be 'https://example.com/example_image.png', but got '%s'", relayConfig.serviceImageURL.String()) } + if relayConfig.OutboundSignatureProfile() != relayhttpsig.ProfileLegacy { + t.Errorf( + "Expected omitted outbound profile to be legacy, got %q", + relayConfig.OutboundSignatureProfile(), + ) + } }) t.Run("Fail to load invalid configuration", func(t *testing.T) { invalidConfig := map[string]string{ - "ACTOR_PEM@notFound": "../misc/test/notfound.pem", - "ACTOR_PEM@invalidKey": "../misc/test/actor.dh.pem", - "REDIS_URL@invalidURL": "", - "REDIS_URL@unreachableHost": "redis://localhost:6380", - "OBSERVABILITY_BIND@missingPort": "127.0.0.1", - "OBSERVABILITY_BIND@zeroPort": "127.0.0.1:0", + "ACTOR_PEM@notFound": "../misc/test/notfound.pem", + "ACTOR_PEM@invalidKey": "../misc/test/actor.dh.pem", + "REDIS_URL@invalidURL": "", + "REDIS_URL@unreachableHost": "redis://localhost:6380", + "OBSERVABILITY_BIND@missingPort": "127.0.0.1", + "OBSERVABILITY_BIND@zeroPort": "127.0.0.1:0", + "OUTBOUND_SIGNATURE_PROFILE@unknown": "automatic", + "OUTBOUND_SIGNATURE_PROFILE@dual": "dual", } for key, value := range invalidConfig { @@ -102,13 +111,14 @@ func TestRelayConfig_DumpWelcomeMessage(t *testing.T) { w := relayConfig.DumpWelcomeMessage("Testing", "") informations := map[string]string{ - "module NAME": "Testing", - "RELAY NAME": relayConfig.serviceName, - "RELAY DOMAIN": relayConfig.domain.Host, - "REDIS URL": relayConfig.redisDisplayURL, - "BIND ADDRESS": relayConfig.serverBind, - "OBSERVABILITY": "disabled", - "JOB_CONCURRENCY": strconv.Itoa(relayConfig.jobConcurrency), + "module NAME": "Testing", + "RELAY NAME": relayConfig.serviceName, + "RELAY DOMAIN": relayConfig.domain.Host, + "REDIS URL": relayConfig.redisDisplayURL, + "BIND ADDRESS": relayConfig.serverBind, + "OBSERVABILITY": "disabled", + "SIGNATURE PROFILE": relayhttpsig.ProfileLegacy.String(), + "JOB_CONCURRENCY": strconv.Itoa(relayConfig.jobConcurrency), } for key, information := range informations { @@ -126,3 +136,14 @@ func TestNewMachineryServer(t *testing.T) { t.Errorf("Expected NewMachineryServer to succeed, but got error: %v", err) } } + +func TestRelayConfigOutboundSignatureProfile(t *testing.T) { + previous := viper.Get("OUTBOUND_SIGNATURE_PROFILE") + defer viper.Set("OUTBOUND_SIGNATURE_PROFILE", previous) + + viper.Set("OUTBOUND_SIGNATURE_PROFILE", "RFC9421") + relayConfig := createRelayConfig(t) + if got := relayConfig.OutboundSignatureProfile(); got != relayhttpsig.ProfileRFC9421 { + t.Fatalf("outbound profile = %q; want rfc9421", got) + } +} diff --git a/models/http_test.go b/models/http_test.go index 996ebc9..7199c75 100644 --- a/models/http_test.go +++ b/models/http_test.go @@ -1,6 +1,7 @@ package models import ( + "context" "crypto/rand" "crypto/rsa" "fmt" @@ -11,6 +12,10 @@ import ( "testing" "time" + "github.com/common-fate/httpsig/alg_rsa" + "github.com/common-fate/httpsig/contentdigest" + "github.com/common-fate/httpsig/sigbase" + "github.com/common-fate/httpsig/sigset" "github.com/go-fed/httpsig" "github.com/patrickmn/go-cache" "github.com/thystra/Activity-Relay/internal/httpsignature" @@ -195,3 +200,191 @@ func TestFetchRemoteJSONRequiresSigner(t *testing.T) { t.Fatalf("expected missing signer error, got %v", err) } } + +func newConfiguredRemoteSignerForTest( + t *testing.T, + profile httpsignature.Profile, +) (*httpsignature.ConfiguredSigner, *rsa.PublicKey) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + signer, err := httpsignature.NewConfiguredSigner( + "https://relay.example/actor#main-key", + privateKey, + profile, + ) + if err != nil { + t.Fatal(err) + } + return signer, &privateKey.PublicKey +} + +func requireValidRFC9421RemoteGET( + request *http.Request, + publicKey *rsa.PublicKey, +) error { + if request.Method != http.MethodGet { + return fmt.Errorf("method = %s; want GET", request.Method) + } + if request.Header.Get("Signature-Input") == "" { + return fmt.Errorf("missing Signature-Input") + } + if request.Header.Get("Digest") != "" || + request.Header.Get("Content-Digest") != "" { + return fmt.Errorf("RFC 9421 GET carries a digest field") + } + + wireRequest := request.Clone(request.Context()) + wireURL := *request.URL + wireURL.Scheme = "http" + wireURL.Host = request.Host + wireRequest.URL = &wireURL + wireRequest.Host = request.Host + + set, err := sigset.Unmarshal(wireRequest) + if err != nil { + return err + } + message, err := set.Find("activitypub") + if err != nil { + return err + } + base, err := sigbase.Derive( + message.Input, + nil, + wireRequest, + contentdigest.SHA256, + ) + if err != nil { + return err + } + signingString, err := base.CanonicalString(message.Input) + if err != nil { + return err + } + return alg_rsa.NewRSAPKCS256Verifier(publicKey).Verify( + context.Background(), + signingString, + message.Signature, + ) +} + +func TestNewActivityPubActorFromRemoteActorSignsRFC9421GET( + t *testing.T, +) { + signer, publicKey := newConfiguredRemoteSignerForTest( + t, + httpsignature.ProfileRFC9421, + ) + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if err := requireValidRFC9421RemoteGET( + request, + publicKey, + ); err != nil { + http.Error( + writer, + err.Error(), + http.StatusUnauthorized, + ) + return + } + writer.Header().Set( + "Content-Type", + "application/activity+json", + ) + fmt.Fprint( + writer, + `{"id":"`+serverURL(request)+`/actor","type":"Service"}`, + ) + }, + )) + defer server.Close() + + actor, err := NewActivityPubActorFromRemoteActor( + server.URL+"/actor", + "Activity-Relay test", + cache.New(5*time.Minute, 10*time.Minute), + signer, + ) + if err != nil { + t.Fatalf("fetch RFC 9421 actor: %v", err) + } + if actor.ID != server.URL+"/actor" { + t.Fatalf("actor ID = %q; want %q", actor.ID, server.URL+"/actor") + } +} + +func TestFetchRemoteJSONResignsRFC9421Redirect(t *testing.T) { + signer, publicKey := newConfiguredRemoteSignerForTest( + t, + httpsignature.ProfileRFC9421, + ) + var verifiedRequests atomic.Int32 + + finalServer := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if err := requireValidRFC9421RemoteGET( + request, + publicKey, + ); err != nil { + http.Error( + writer, + err.Error(), + http.StatusUnauthorized, + ) + return + } + verifiedRequests.Add(1) + writer.Header().Set( + "Content-Type", + "application/activity+json", + ) + fmt.Fprint( + writer, + `{"id":"`+serverURL(request)+`/actor","type":"Service"}`, + ) + }, + )) + defer finalServer.Close() + + redirectServer := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if err := requireValidRFC9421RemoteGET( + request, + publicKey, + ); err != nil { + http.Error( + writer, + err.Error(), + http.StatusUnauthorized, + ) + return + } + verifiedRequests.Add(1) + http.Redirect( + writer, + request, + finalServer.URL+"/actor", + http.StatusFound, + ) + }, + )) + defer redirectServer.Close() + + var actor Actor + if _, err := fetchRemoteJSON( + redirectServer.URL+"/actor", + "test", + &actor, + signer, + ); err != nil { + t.Fatalf("fetch redirected RFC 9421 actor: %v", err) + } + if got := verifiedRequests.Load(); got != 2 { + t.Fatalf("verified RFC 9421 requests = %d; want 2", got) + } +} diff --git a/models/models.go b/models/models.go index f741f31..ba729d9 100644 --- a/models/models.go +++ b/models/models.go @@ -57,6 +57,8 @@ func fetchRemoteJSON(address string, uaString string, destination interface{}, s } redirect.Header.Del("Date") redirect.Header.Del("Digest") + redirect.Header.Del("Content-Digest") + redirect.Header.Del("Signature-Input") redirect.Header.Del("Signature") if err := signer.SignGET(redirect); err != nil { return fmt.Errorf("sign redirected remote request: %w", err) diff --git a/readme.md b/readme.md index b20e63e..a0fb2af 100644 --- a/readme.md +++ b/readme.md @@ -289,6 +289,9 @@ MAX_ACTIVITY_BYTES: 1048576 MAX_FANOUT_TARGETS: 5000 MAX_QUEUE_JOBS: 100000 +# Outbound authorized-fetch and delivery signing. Omitted defaults to legacy. +OUTBOUND_SIGNATURE_PROFILE: legacy + # RELAY_SUMMARY: | # Optional public relay branding. These are interoperability recommendations, # not protocol limits; clients may crop or rescale the supplied images. @@ -303,6 +306,12 @@ MAX_QUEUE_JOBS: 100000 Use `127.0.0.1:8080` instead when the relay and reverse proxy run directly on the same host. +`OUTBOUND_SIGNATURE_PROFILE` accepts `legacy` or `rfc9421`. Empty or +omitted configuration preserves the established legacy signatures. `dual` is +rejected until destination-aware negotiation is implemented. The same value is +used by the API server for signed remote GETs and by every worker for delivery +POSTs. + `RELAY_ICON` and `RELAY_IMAGE` are optional public metadata URLs. The suggested dimensions are compatibility-oriented recommendations rather than enforced limits. A square icon is least likely to be distorted by clients, while a 3:1 @@ -323,6 +332,7 @@ JOB_CONCURRENCY MAX_ACTIVITY_BYTES MAX_FANOUT_TARGETS MAX_QUEUE_JOBS +OUTBOUND_SIGNATURE_PROFILE RELAY_SUMMARY RELAY_ICON RELAY_IMAGE @@ -474,10 +484,13 @@ in `/actor`, `/relay`, `/friendica`, or another implementation-defined path. Legacy `/relay` and `/friendica` actors with incomplete type metadata remain supported. -Outbound deliveries sign the same `Host` authority that is transmitted on the -wire, including a non-default port. Bounded non-success response text is -included in worker errors to make remote signature-verification failures -diagnosable without logging unbounded response bodies. +Outbound authorized fetches and deliveries use `OUTBOUND_SIGNATURE_PROFILE`. +The default `legacy` profile preserves the established wire format. Explicit +`rfc9421` selection uses RFC 9421 and RFC 9530 without changing the actor key or +key ID. Both profiles sign the same authority transmitted on the wire, +including a non-default port. Bounded non-success response text is included in +worker errors to make remote signature-verification failures diagnosable +without logging unbounded response bodies. ## Public Announce interoperability