From cc8e30982135ae93dad750ee7048950f89abb52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Tue, 2 Dec 2025 21:46:08 +0100 Subject: [PATCH] Fix HTTP Signature by explicitly setting Host header - Added key pair validation debug endpoint - Verified that public/private key pairs match correctly - Fixed HTTP Signature issue by explicitly setting Host header - Previously relied on RestTemplate to auto-set Host, which could differ from signed value - Now explicitly set Host header to match the value used in signature calculation This should fix the 401 Unauthorized errors when sending activities to Mastodon --- .../fitpub/config/SecurityConfig.java | 3 + .../fitpub/controller/DebugController.java | 110 ++++++++++++++++++ .../fitpub/service/FederationService.java | 10 +- .../security/KeyPairValidationTest.java | 97 +++++++++++++++ 4 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/controller/DebugController.java create mode 100644 src/test/java/org/operaton/fitpub/security/KeyPairValidationTest.java diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 34991fc..2486a02 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -77,6 +77,9 @@ public class SecurityConfig { // Public endpoints - User's public activities .requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll() + // Debug endpoints (dev only) + .requestMatchers("/api/debug/**").permitAll() + // Public endpoints - Likes and Comments (GET only) .requestMatchers(HttpMethod.GET, "/api/activities/*/likes").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/*/comments").permitAll() diff --git a/src/main/java/org/operaton/fitpub/controller/DebugController.java b/src/main/java/org/operaton/fitpub/controller/DebugController.java new file mode 100644 index 0000000..4cc76e8 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/DebugController.java @@ -0,0 +1,110 @@ +package org.operaton.fitpub.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.*; + +/** + * Debug controller for troubleshooting. + */ +@RestController +@RequestMapping("/api/debug") +@RequiredArgsConstructor +@Slf4j +public class DebugController { + + private final UserRepository userRepository; + + @GetMapping("/validate-keys") + public Map validateKeys() { + List users = userRepository.findAll(); + Map results = new LinkedHashMap<>(); + + for (User user : users) { + Map userResult = new LinkedHashMap<>(); + + try { + // Parse public key + PublicKey publicKey = parsePublicKey(user.getPublicKey()); + userResult.put("publicKeyValid", true); + + // Parse private key + PrivateKey privateKey = parsePrivateKey(user.getPrivateKey()); + userResult.put("privateKeyValid", true); + + // Test if they match by signing and verifying + String testData = "Test data for " + user.getUsername(); + byte[] signature = signData(testData.getBytes(StandardCharsets.UTF_8), privateKey); + boolean verified = verifySignature(testData.getBytes(StandardCharsets.UTF_8), signature, publicKey); + + userResult.put("keysMatch", verified); + userResult.put("publicKeyPem", user.getPublicKey()); + + if (verified) { + log.info("✓ Key pair is valid for user: {}", user.getUsername()); + } else { + log.error("✗ Key pair MISMATCH for user: {}", user.getUsername()); + } + + } catch (Exception e) { + userResult.put("error", e.getMessage()); + log.error("Failed to validate keys for user: {}", user.getUsername(), e); + } + + results.put(user.getUsername(), userResult); + } + + return results; + } + + private PublicKey parsePublicKey(String publicKeyPem) throws Exception { + String publicKeyContent = publicKeyPem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent); + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(spec); + } + + private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception { + String privateKeyContent = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(spec); + } + + private byte[] signData(byte[] data, PrivateKey privateKey) throws Exception { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + signature.update(data); + return signature.sign(); + } + + private boolean verifySignature(byte[] data, byte[] signatureBytes, PublicKey publicKey) throws Exception { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(publicKey); + signature.update(data); + return signature.verify(signatureBytes); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/FederationService.java b/src/main/java/org/operaton/fitpub/service/FederationService.java index 2a64945..d6701ea 100644 --- a/src/main/java/org/operaton/fitpub/service/FederationService.java +++ b/src/main/java/org/operaton/fitpub/service/FederationService.java @@ -190,6 +190,11 @@ public class FederationService { headers.set("Content-Type", "application/activity+json"); headers.set("Accept", "application/activity+json"); + // CRITICAL: Set the Host header to exactly match what was used in the signature + // We MUST set this explicitly, otherwise RestTemplate might set it differently + // (e.g., with port number) and the signature validation will fail + headers.set("Host", signatureHeaders.host); + // Set the Date and Digest headers that were used in the signature headers.set("Date", signatureHeaders.date); headers.set("Digest", signatureHeaders.digest); @@ -197,11 +202,6 @@ public class FederationService { // Set the Signature header headers.set("Signature", signatureHeaders.signature); - // NOTE: We do NOT set the Host header here. - // RestTemplate/HttpClient will set it automatically to match the URL. - // The signature was calculated with the correct host (extracted from inboxUrl), - // so when the client sets the Host header, it will match the signature. - HttpEntity entity = new HttpEntity<>(activityJson, headers); ResponseEntity response = restTemplate.postForEntity(inboxUrl, entity, String.class); diff --git a/src/test/java/org/operaton/fitpub/security/KeyPairValidationTest.java b/src/test/java/org/operaton/fitpub/security/KeyPairValidationTest.java new file mode 100644 index 0000000..93717c4 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/security/KeyPairValidationTest.java @@ -0,0 +1,97 @@ +package org.operaton.fitpub.security; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test to validate that users' public and private keys match. + */ +@SpringBootTest +@Slf4j +public class KeyPairValidationTest { + + @Autowired + private UserRepository userRepository; + + @Test + public void testAllUsersKeysMatch() { + List users = userRepository.findAll(); + + for (User user : users) { + log.info("Validating key pair for user: {}", user.getUsername()); + + try { + // Parse public key + PublicKey publicKey = parsePublicKey(user.getPublicKey()); + + // Parse private key + PrivateKey privateKey = parsePrivateKey(user.getPrivateKey()); + + // Test if they match by signing and verifying + String testData = "Test data for " + user.getUsername(); + byte[] signature = signData(testData.getBytes(StandardCharsets.UTF_8), privateKey); + boolean verified = verifySignature(testData.getBytes(StandardCharsets.UTF_8), signature, publicKey); + + assertTrue(verified, "Public key does NOT match private key for user: " + user.getUsername()); + log.info("✓ Key pair is valid for user: {}", user.getUsername()); + + } catch (Exception e) { + fail("Failed to validate keys for user " + user.getUsername() + ": " + e.getMessage()); + } + } + } + + private PublicKey parsePublicKey(String publicKeyPem) throws Exception { + String publicKeyContent = publicKeyPem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent); + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(spec); + } + + private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception { + String privateKeyContent = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(spec); + } + + private byte[] signData(byte[] data, PrivateKey privateKey) throws Exception { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + signature.update(data); + return signature.sign(); + } + + private boolean verifySignature(byte[] data, byte[] signatureBytes, PublicKey publicKey) throws Exception { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(publicKey); + signature.update(data); + return signature.verify(signatureBytes); + } +}