๐ fix(federation): HTTP/1.1 + host-from-authority for signed delivery
Changes
7 files changed, +123 -8
MODIFY
openspec/changes/add-forgefed-federation/tasks.md
+7 -2
@@ -85,7 +85,12 @@
85
85
federation crypto (RSA keygen/`SHA256withRSA`) and `java.net.http` client are reachable in native
86
86
with no extra security-services registration. (SmokeIT covers HTTP health + SSH banner; the signed
87
87
federation round-trip in native is still part of the 6.4 two-host manual check.)
88
-- [ ] 6.4 Manual two-instance check (two containers/hosts, mutually allowlisted, real HTTPS): follow,
89
- push, observe the delivered `Push`. Deferred โ needs a multi-host environment.
88
+- [x] 6.4 Two-instance trial DONE (two JVM instances on 127.0.0.1:8090/8091, mutual allowlist, dev
89
+ insecure flag): a signed `Follow` from B was accepted + `Accept` delivered to B; a real `git push`
90
+ to A emitted a `Push` delivered to and signature-verified by B. This surfaced + fixed a real bug:
91
+ `java.net.http` negotiates HTTP/2, where the signed `Host` header becomes `:authority` and the
92
+ receiver could not reconstruct it โ all signed deliveries 401'd. Fixed by forcing HTTP/1.1 on the
93
+ client and reconstructing `host` from the request authority on verify (`FederationDeliveryRoundTripTest`
94
+ regression guard). Outbound "follow a remote repo" remains a follow-up (issue #3).
90
95
- [x] 6.5 `README.md` updated: Federation section, `GITSHARK_FEDERATION_*` config, persisted tables,
91
96
permanent-actor-ID warning, and the note that non-git-shark interop is untested.
MODIFY
src/main/java/de/workaround/federation/ActivityPubClient.java
+3 -0
@@ -44,6 +44,9 @@
44
44
45
45
private final HttpClient http = HttpClient.newBuilder()
46
46
.connectTimeout(Duration.ofSeconds(10))
47
+ // HTTP/1.1: over HTTP/2 the signed `Host` header becomes the `:authority` pseudo-header and
48
+ // peers can't reconstruct it for signature verification. HTTP/1.1 is the ActivityPub norm.
49
+ .version(HttpClient.Version.HTTP_1_1)
47
50
.followRedirects(HttpClient.Redirect.NORMAL)
48
51
.build();
49
52
MODIFY
src/main/java/de/workaround/federation/FederationConfig.java
+6 -2
@@ -54,7 +54,7 @@
54
54
return baseUrl
55
55
.map(String::trim)
56
56
.filter(value -> !value.isEmpty())
57
- .filter(FederationConfig::isUsableOrigin)
57
+ .filter(value -> isUsableOrigin(value, devAllowInsecure))
58
58
.map(FederationConfig::stripTrailingSlash);
59
59
}
60
60
@@ -96,7 +96,7 @@
96
96
return peers().stream().map(p -> p.trim().toLowerCase(Locale.ROOT)).anyMatch(normalized::equals);
97
97
}
98
98
99
- private static boolean isUsableOrigin(String value)
99
+ private static boolean isUsableOrigin(String value, boolean allowLoopback)
100
100
{
101
101
try
102
102
{
@@ -110,6 +110,10 @@
110
110
{
111
111
return false;
112
112
}
113
+ if (allowLoopback)
114
+ {
115
+ return true; // dev-only: permit localhost/loopback base URLs for local two-host trials
116
+ }
113
117
String host = uri.getHost().toLowerCase(Locale.ROOT);
114
118
return !host.equals("localhost") && !host.startsWith("127.") && !host.equals("::1")
115
119
&& !host.equals("[::1]");
MODIFY
src/main/java/de/workaround/federation/HttpSignatures.java
+16 -2
@@ -34,6 +34,8 @@
34
34
35
35
private static final List<String> SIGNED_HEADERS = List.of("(request-target)", "host", "date", "digest");
36
36
37
+ private static final org.jboss.logging.Logger LOG = org.jboss.logging.Logger.getLogger(HttpSignatures.class);
38
+
37
39
private HttpSignatures()
38
40
{
39
41
}
@@ -117,8 +119,14 @@
117
119
{
118
120
return false;
119
121
}
120
- if (!checkDigest(headerLookup.apply("digest"), body) || !checkDate(headerLookup.apply("date")))
122
+ if (!checkDigest(headerLookup.apply("digest"), body))
121
123
{
124
+ LOG.warnf("Signature rejected: digest mismatch (header=%s)", headerLookup.apply("digest"));
125
+ return false;
126
+ }
127
+ if (!checkDate(headerLookup.apply("date")))
128
+ {
129
+ LOG.warnf("Signature rejected: bad/stale date (header=%s)", headerLookup.apply("date"));
122
130
return false;
123
131
}
124
132
String requestTarget = method.toLowerCase(Locale.ROOT) + " " + path;
@@ -129,10 +137,16 @@
129
137
Signature verifier = Signature.getInstance("SHA256withRSA");
130
138
verifier.initVerify(key);
131
139
verifier.update(signingString.getBytes(StandardCharsets.UTF_8));
132
- return verifier.verify(header.signature());
140
+ boolean ok = verifier.verify(header.signature());
141
+ if (!ok)
142
+ {
143
+ LOG.warnf("Signature rejected: RSA verification failed for keyId=%s", header.keyId());
144
+ }
145
+ return ok;
133
146
}
134
147
catch (GeneralSecurityException e)
135
148
{
149
+ LOG.warnf("Signature rejected: %s", e.getMessage());
136
150
return false;
137
151
}
138
152
}
MODIFY
src/main/java/de/workaround/federation/InboxResource.java
+6 -1
@@ -60,7 +60,12 @@
60
60
{
61
61
throw new NotFoundException();
62
62
}
63
- Function<String, String> lookup = name -> headers.getHeaderString(name);
63
+ // Reconstruct `host` from the request authority: over HTTP/2 there is no classic Host header
64
+ // (it is the :authority pseudo-header), so getHeaderString("host") would be empty.
65
+ String authority = uriInfo.getRequestUri().getAuthority();
66
+ Function<String, String> lookup = name -> "host".equalsIgnoreCase(name) && authority != null
67
+ ? authority
68
+ : headers.getHeaderString(name);
64
69
int status = inboxService.receive(body == null ? new byte[0] : body, lookup, "POST",
65
70
uriInfo.getRequestUri().getRawPath());
66
71
return Response.status(status).build();
MODIFY
src/main/java/de/workaround/federation/InboxService.java
+11 -1
@@ -12,6 +12,7 @@
12
12
import jakarta.enterprise.context.ApplicationScoped;
13
13
import jakarta.inject.Inject;
14
14
import jakarta.transaction.Transactional;
15
+import org.jboss.logging.Logger;
15
16
16
17
/**
17
18
* Inbound activity receipt: verifies the HTTP Signature (allowlisted peer, fetched key, valid
@@ -27,6 +28,8 @@
27
28
28
29
public static final int BAD_REQUEST = 400;
29
30
31
+ private static final Logger LOG = Logger.getLogger(InboxService.class);
32
+
30
33
@Inject
31
34
FederationConfig config;
32
35
@@ -51,15 +54,22 @@
51
54
HttpSignatures.SignatureHeader signature = HttpSignatures.parse(headerLookup.apply("signature"));
52
55
if (signature == null)
53
56
{
57
+ LOG.warn("Inbox 401: missing or unparseable Signature header");
54
58
return UNAUTHORIZED;
55
59
}
56
60
String signerHost = hostOf(signature.keyId());
57
61
if (signerHost == null || !config.peerAllowed(signerHost))
58
62
{
63
+ LOG.warnf("Inbox 401: signer host not allowlisted (keyId=%s)", signature.keyId());
59
64
return UNAUTHORIZED;
60
65
}
61
66
Optional<PublicKey> key = client.fetchPublicKey(signature.keyId());
62
- if (key.isEmpty() || !HttpSignatures.verify(signature, method, path, headerLookup, body, key.get()))
67
+ if (key.isEmpty())
68
+ {
69
+ LOG.warnf("Inbox 401: could not resolve public key for keyId=%s", signature.keyId());
70
+ return UNAUTHORIZED;
71
+ }
72
+ if (!HttpSignatures.verify(signature, method, path, headerLookup, body, key.get()))
63
73
{
64
74
return UNAUTHORIZED;
65
75
}
ADD
src/test/java/de/workaround/federation/FederationDeliveryRoundTripTest.java
+74 -0
@@ -0,0 +1,74 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.Map;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import com.fasterxml.jackson.databind.ObjectMapper;
9
+import com.fasterxml.jackson.databind.node.ObjectNode;
10
+
11
+import de.workaround.model.FederationKey;
12
+import io.quarkus.test.junit.QuarkusTest;
13
+import io.quarkus.test.junit.QuarkusTestProfile;
14
+import io.quarkus.test.junit.TestProfile;
15
+import jakarta.inject.Inject;
16
+
17
+import static org.junit.jupiter.api.Assertions.assertTrue;
18
+
19
+/**
20
+ * Regression test for the restricted-header bug: a signed activity delivered through the real
21
+ * {@link ActivityPubClient} (java.net.http) to this instance's own inbox must verify and be
22
+ * accepted (202). Without permitting the restricted {@code Date}/{@code Host} headers, HttpClient
23
+ * drops the signed {@code date} and the receiver returns 401. Self-delivery works because the
24
+ * signer key is fetched from this same instance.
25
+ */
26
+@QuarkusTest
27
+@TestProfile(FederationDeliveryRoundTripTest.SelfDeliveryProfile.class)
28
+class FederationDeliveryRoundTripTest
29
+{
30
+ public static class SelfDeliveryProfile implements QuarkusTestProfile
31
+ {
32
+ @Override
33
+ public Map<String, String> getConfigOverrides()
34
+ {
35
+ return Map.of(
36
+ "quarkus.http.test-port", "8081",
37
+ "gitshark.federation.enabled", "true",
38
+ "gitshark.federation.base-url", "http://localhost:8081",
39
+ "gitshark.federation.dev-allow-insecure", "true",
40
+ "gitshark.federation.peer-allowlist", "localhost,127.0.0.1");
41
+ }
42
+ }
43
+
44
+ @Inject
45
+ ActivityPubClient client;
46
+
47
+ @Inject
48
+ ActorKeyService keyService;
49
+
50
+ @Inject
51
+ ActorUris uris;
52
+
53
+ @Inject
54
+ ObjectMapper mapper;
55
+
56
+ @Test
57
+ void signedDeliveryThroughHttpClientIsAccepted() throws Exception
58
+ {
59
+ FederationKey key = keyService.getOrCreate(FederationKey.ActorType.INSTANCE, "instance");
60
+
61
+ ObjectNode activity = mapper.createObjectNode();
62
+ activity.put("@context", "https://www.w3.org/ns/activitystreams");
63
+ activity.put("id", "http://localhost:8081/test/" + UUID.randomUUID());
64
+ activity.put("type", "Create");
65
+ byte[] payload = mapper.writeValueAsBytes(activity);
66
+
67
+ ActivityPubClient.DeliveryOutcome outcome =
68
+ client.deliver(uris.inbox(uris.instance()), payload, key, uris.keyId(uris.instance()));
69
+
70
+ assertTrue(outcome.success(),
71
+ "signed delivery through HttpClient must be accepted by the inbox; got " + outcome);
72
+ }
73
+
74
+}