From 6aa3cbb5f6085f16d8b832618124a44ea38be086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Mon, 1 Dec 2025 11:04:51 +0100 Subject: [PATCH] Fix federation --- .../operaton/fitpub/FitPubApplication.java | 12 ++- .../ActivityPubHttpRequestInterceptor.java | 91 +++++++++++++++++++ .../fitpub/service/FederationService.java | 28 +++--- 3 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/config/ActivityPubHttpRequestInterceptor.java diff --git a/src/main/java/org/operaton/fitpub/FitPubApplication.java b/src/main/java/org/operaton/fitpub/FitPubApplication.java index 8625f6c..3dc8b27 100644 --- a/src/main/java/org/operaton/fitpub/FitPubApplication.java +++ b/src/main/java/org/operaton/fitpub/FitPubApplication.java @@ -33,12 +33,11 @@ public class FitPubApplication { /** * REST template for making HTTP requests to remote ActivityPub servers. - * Configured to not suppress HTTP headers, which is critical for HTTP Signature authentication. + * Configured with HTTP Signature interceptor for ActivityPub federation. */ @Bean - public RestTemplate restTemplate() { + public RestTemplate restTemplate(org.operaton.fitpub.config.ActivityPubHttpRequestInterceptor interceptor) { // Use Apache HttpClient with custom configuration - // This prevents automatic Host header overwriting which breaks HTTP Signatures HttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .build(); @@ -49,6 +48,11 @@ public class FitPubApplication { HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); - return new RestTemplate(requestFactory); + RestTemplate restTemplate = new RestTemplate(requestFactory); + + // Add HTTP Signature interceptor + restTemplate.getInterceptors().add(interceptor); + + return restTemplate; } } diff --git a/src/main/java/org/operaton/fitpub/config/ActivityPubHttpRequestInterceptor.java b/src/main/java/org/operaton/fitpub/config/ActivityPubHttpRequestInterceptor.java new file mode 100644 index 0000000..be1e5c5 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/config/ActivityPubHttpRequestInterceptor.java @@ -0,0 +1,91 @@ +package org.operaton.fitpub.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.security.HttpSignatureValidator; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Intercepts outgoing HTTP requests for ActivityPub federation to add HTTP Signatures. + * This interceptor is applied AFTER RestTemplate sets all headers (including Host), + * ensuring the signature matches the actual headers sent. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ActivityPubHttpRequestInterceptor implements ClientHttpRequestInterceptor { + + private final HttpSignatureValidator signatureValidator; + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + + // Check if this request needs HTTP Signature (look for a marker header) + String privateKey = request.getHeaders().getFirst("X-ActivityPub-PrivateKey"); + String keyId = request.getHeaders().getFirst("X-ActivityPub-KeyId"); + + if (privateKey != null && keyId != null) { + // Remove marker headers (they shouldn't be sent) + request.getHeaders().remove("X-ActivityPub-PrivateKey"); + request.getHeaders().remove("X-ActivityPub-KeyId"); + + try { + // Now sign the request with the actual headers that will be sent + String method = request.getMethod().name(); + String uri = request.getURI().toString(); + String bodyString = new String(body, StandardCharsets.UTF_8); + + // Get the actual Host header that RestTemplate set + String host = request.getHeaders().getFirst("Host"); + if (host == null) { + host = request.getURI().getHost(); + } + + // Get the actual Date header that was set + String date = request.getHeaders().getFirst("Date"); + + // Get the actual Digest header that was set + String digest = request.getHeaders().getFirst("Digest"); + + // Build the signing string with the ACTUAL header values + String signingString = String.format( + "(request-target): %s %s%s\nhost: %s\ndate: %s\ndigest: %s", + method.toLowerCase(), + request.getURI().getPath(), + request.getURI().getQuery() != null ? "?" + request.getURI().getQuery() : "", + host, + date, + digest + ); + + // Sign the string + String signatureBase64 = signatureValidator.sign(signingString, privateKey); + + // Build signature header + String signatureHeader = String.format( + "keyId=\"%s\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"%s\"", + keyId, signatureBase64 + ); + + // Add signature header + request.getHeaders().set("Signature", signatureHeader); + + log.debug("Added HTTP Signature to request: {}", request.getURI()); + + } catch (Exception e) { + log.error("Failed to sign request", e); + throw new IOException("Failed to sign ActivityPub request", e); + } + } + + return execution.execute(request, body); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/FederationService.java b/src/main/java/org/operaton/fitpub/service/FederationService.java index 68eadd6..4cf8749 100644 --- a/src/main/java/org/operaton/fitpub/service/FederationService.java +++ b/src/main/java/org/operaton/fitpub/service/FederationService.java @@ -166,25 +166,25 @@ public class FederationService { try { String activityJson = objectMapper.writeValueAsString(activity); - // Generate HTTP signature with all required headers - HttpSignatureValidator.SignatureHeaders signatureHeaders = signatureValidator.signRequest( - HttpMethod.POST.name(), - inboxUrl, - activityJson, - sender.getPrivateKey(), - baseUrl + "/users/" + sender.getUsername() + "#main-key" - ); + // Calculate Date and Digest headers + java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC); + java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; + String date = now.format(formatter); + + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(activityJson.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String digestValue = "SHA-256=" + java.util.Base64.getEncoder().encodeToString(hash); HttpHeaders headers = new HttpHeaders(); headers.set("Content-Type", "application/activity+json"); headers.set("Accept", "application/activity+json"); + headers.set("Date", date); + headers.set("Digest", digestValue); - // Add signature-related headers - // NOTE: We do NOT set the Host header manually - RestTemplate/HttpClient sets it automatically - // The signature was calculated with the correct host from the URL, so it will match - headers.set("Date", signatureHeaders.date); - headers.set("Digest", signatureHeaders.digest); - headers.set("Signature", signatureHeaders.signature); + // Set marker headers for the interceptor to use for signing + // These will be removed by the interceptor before sending + headers.set("X-ActivityPub-PrivateKey", sender.getPrivateKey()); + headers.set("X-ActivityPub-KeyId", baseUrl + "/users/" + sender.getUsername() + "#main-key"); HttpEntity entity = new HttpEntity<>(activityJson, headers);