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
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions api/outbound_profile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions config.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 14 additions & 4 deletions deliver/deliver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions deliver/outbound_profile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 13 additions & 1 deletion deliver/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
94 changes: 93 additions & 1 deletion deliver/sender_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package deliver

import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"io"
Expand All @@ -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) {
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}
}
41 changes: 31 additions & 10 deletions docs/HTTP-MESSAGE-SIGNATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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.
31 changes: 31 additions & 0 deletions docs/INTEGRATION-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading