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
This commit is contained in:
Tim Zöller 2025-12-02 21:46:08 +01:00
parent 42a585220f
commit cc8e309821
4 changed files with 215 additions and 5 deletions

View file

@ -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()

View file

@ -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<String, Object> validateKeys() {
List<User> users = userRepository.findAll();
Map<String, Object> results = new LinkedHashMap<>();
for (User user : users) {
Map<String, Object> 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);
}
}

View file

@ -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<String> entity = new HttpEntity<>(activityJson, headers);
ResponseEntity<String> response = restTemplate.postForEntity(inboxUrl, entity, String.class);

View file

@ -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<User> 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);
}
}