diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java
index 617505e..51e8a8c 100644
--- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java
+++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java
@@ -1,15 +1,21 @@
package net.javahippie.fitpub.controller;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.activitypub.Actor;
import net.javahippie.fitpub.model.activitypub.OrderedCollection;
import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository;
+import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.service.ActivityImageService;
+import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.InboxProcessor;
import net.javahippie.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
@@ -19,7 +25,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.*;
+import java.util.regex.Pattern;
/**
* ActivityPub protocol controller.
@@ -37,6 +48,9 @@ public class ActivityPubController {
private final ActivityImageService activityImageService;
private final InboxProcessor inboxProcessor;
private final FollowRepository followRepository;
+ private final HttpSignatureValidator signatureValidator;
+ private final FederationService federationService;
+ private final ObjectMapper objectMapper;
@Value("${fitpub.base-url}")
private String baseUrl;
@@ -44,6 +58,9 @@ public class ActivityPubController {
private static final String ACTIVITY_JSON = "application/activity+json";
private static final String LD_JSON = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
+ /** Matches the keyId field of an HTTP Signature header. */
+ private static final Pattern SIGNATURE_KEY_ID_PATTERN = Pattern.compile("keyId=\"([^\"]+)\"");
+
/**
* Actor profile endpoint.
* Returns the ActivityPub Actor object for a user.
@@ -74,9 +91,23 @@ public class ActivityPubController {
* Inbox endpoint for receiving ActivityPub activities.
* POST /users/{username}/inbox
*
- * @param username the username
- * @param activity the incoming activity
- * @return accepted response
+ *
Performs full HTTP-Signature validation on every incoming request:
+ *
+ * - Reject if {@code Signature} or {@code Digest} headers are missing.
+ * - Verify the {@code Digest} header actually matches the body's SHA-256 hash.
+ * - Resolve the signing key by fetching the actor referenced in {@code keyId}.
+ * - Verify the request signature with the actor's public key.
+ * - Bind the activity's {@code actor} field to the signing key's host so a federated
+ * server cannot deliver activities on behalf of users on a different host.
+ *
+ * Any failure of steps 1–5 produces a 401. Transient upstream failures (cannot fetch the
+ * actor) produce 502 so the sender will retry per ActivityPub spec.
+ *
+ * @param username the local recipient username
+ * @param body the raw request body bytes (preserved for digest verification)
+ * @param request the inbound request, used for header collection and request-target
+ * @return 202 Accepted on success, 401 Unauthorized on signature failures, 502 on upstream
+ * failures, 400 on malformed JSON
*/
@PostMapping(
value = "/users/{username}/inbox",
@@ -84,24 +115,168 @@ public class ActivityPubController {
)
public ResponseEntity inbox(
@PathVariable String username,
- @RequestBody Map activity,
- @RequestHeader(value = "Signature", required = false) String signature
+ @RequestBody byte[] body,
+ HttpServletRequest request
) {
- log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type"));
+ // 1. Signature header required
+ String signatureHeader = request.getHeader("Signature");
+ if (signatureHeader == null || signatureHeader.isBlank()) {
+ log.warn("Inbox request for user {} missing Signature header", username);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
- // TODO: Validate HTTP signature (signature validation can be added later)
+ // 2. Digest header required and must actually match the body.
+ // The signature covers the digest header value, but verifying the digest against the
+ // real body closes the loop: an attacker who lifted a signed envelope cannot swap the
+ // payload underneath it.
+ String digestHeader = request.getHeader("Digest");
+ if (digestHeader == null || digestHeader.isBlank()) {
+ log.warn("Inbox request for user {} missing Digest header", username);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ String expectedDigest = computeSha256Digest(body);
+ if (!expectedDigest.equals(digestHeader.trim())) {
+ log.warn("Inbox request for user {}: Digest header does not match body hash", username);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
- // Process activity asynchronously to avoid blocking the sender
+ // 3. Extract the keyId from the signature header
+ String keyId = extractKeyId(signatureHeader);
+ if (keyId == null) {
+ log.warn("Inbox request for user {}: Signature header has no keyId", username);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ // The keyId is conventionally "https://example.com/users/alice#main-key"; strip
+ // the fragment to get the actor URI we should fetch.
+ String actorUriFromKey = keyId.contains("#") ? keyId.substring(0, keyId.indexOf('#')) : keyId;
+ URI keyHostUri;
+ try {
+ keyHostUri = new URI(actorUriFromKey);
+ if (keyHostUri.getHost() == null) {
+ throw new URISyntaxException(actorUriFromKey, "missing host");
+ }
+ } catch (URISyntaxException e) {
+ log.warn("Inbox request for user {}: keyId is not a valid URI: {}", username, keyId);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ // 4. Fetch the actor and obtain their public key
+ String publicKeyPem;
+ try {
+ RemoteActor remoteActor = federationService.fetchRemoteActor(actorUriFromKey);
+ publicKeyPem = remoteActor.getPublicKey();
+ } catch (Exception e) {
+ // Couldn't reach upstream — treat as transient and let them retry
+ log.warn("Inbox request for user {}: failed to fetch remote actor {} for signature verification",
+ username, actorUriFromKey, e);
+ return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
+ }
+ if (publicKeyPem == null || publicKeyPem.isBlank()) {
+ log.warn("Inbox request for user {}: remote actor {} has no public key",
+ username, actorUriFromKey);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ // 5. Verify the signature against all the headers it claims to cover
+ Map headers = collectHeaders(request);
+ if (!signatureValidator.validate(signatureHeader, headers, publicKeyPem)) {
+ log.warn("Inbox request for user {}: HTTP signature verification failed (signed by {})",
+ username, actorUriFromKey);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ // 6. Parse the JSON payload (only after the signature is verified, so we don't
+ // waste cycles on unauthenticated input)
+ Map activity;
+ try {
+ activity = objectMapper.readValue(body, new TypeReference