From cdd03aa64548810591e043fb59a287a1b36c92ba Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 5 Jan 2023 11:00:50 +0300 Subject: ActivityPub: signed GET requests, fix Signature verification --- .../java/com/juick/service/ActivityPubService.java | 158 ++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) (limited to 'src/main/java/com/juick/service/ActivityPubService.java') diff --git a/src/main/java/com/juick/service/ActivityPubService.java b/src/main/java/com/juick/service/ActivityPubService.java index c45b0c13..f161be7b 100644 --- a/src/main/java/com/juick/service/ActivityPubService.java +++ b/src/main/java/com/juick/service/ActivityPubService.java @@ -18,24 +18,71 @@ package com.juick.service; import com.juick.model.User; +import com.juick.service.activities.DeleteUserEvent; +import com.juick.util.DateFormattersHolder; +import com.juick.www.api.activity.model.Context; +import com.juick.www.api.activity.model.objects.Actor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.KeystoreManager; import com.juick.model.AnonymousUser; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.convert.ConversionService; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.*; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.tomitribe.auth.signatures.MissingRequiredHeaderException; +import org.tomitribe.auth.signatures.Signature; +import org.tomitribe.auth.signatures.Verifier; import javax.annotation.Nonnull; import javax.inject.Inject; + +import java.io.IOException; +import java.net.URI; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.time.Instant; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Optional; @Repository public class ActivityPubService extends BaseJdbcService implements SocialService { + + private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); + @Value("${ap_base_uri:http://localhost:8080/}") private String baseUri; @Inject private UserService userService; + @Inject + private RestTemplate restClient; + @Inject + private ObjectMapper jsonMapper; + @Inject + private SignatureService signatureService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Inject + private KeystoreManager keystoreManager; + @Inject + private User serviceUser; + @Inject + private ConversionService conversionService; @Transactional(readOnly = true) @Override @@ -53,7 +100,8 @@ public class ActivityPubService extends BaseJdbcService implements SocialService @Transactional(readOnly = true) @Override public @Nonnull List getFollowers(User user) { - return getJdbcTemplate().queryForList("SELECT acct FROM followers WHERE user_id=?", String.class, user.getUid()); + return getJdbcTemplate().queryForList("SELECT acct FROM followers WHERE user_id=?", String.class, + user.getUid()); } @Transactional @@ -78,4 +126,112 @@ public class ActivityPubService extends BaseJdbcService implements SocialService public void removeAccount(String acct) { getJdbcTemplate().update("DELETE FROM followers WHERE acct=?", acct); } + + @Cacheable("profiles") + public Optional get(URI contextUri) { + Instant now = Instant.now(); + String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + String host = contextUri.getPort() > 0 ? String.format("%s:%d", contextUri.getHost(), contextUri.getPort()) + : contextUri.getHost(); + var from = conversionService.convert(serviceUser, Actor.class); + try { + String signatureString = signatureService.addSignature(from, host, "get", contextUri.getPath(), requestDate, + ""); + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Date", requestDate); + requestHeaders.add("Host", host); + requestHeaders.add("Signature", signatureString); + requestHeaders.setAccept(Collections.singletonList(MediaType.valueOf(Context.ACTIVITY_MEDIA_TYPE))); + HttpEntity activityRequest = new HttpEntity<>(requestHeaders); + var response = restClient.exchange(contextUri, HttpMethod.GET, activityRequest, Context.class); + if (response.getStatusCode().is2xxSuccessful()) { + var context = response.getBody(); + if (context == null) { + logger.warn("Cannot identify {}", contextUri); + return Optional.empty(); + } + return Optional.of(context); + } + } catch (IOException e) { + logger.warn("HTTP Signature exception: {}", e.getMessage()); + } + return Optional.empty(); + } + + public HttpStatusCode post(Actor from, Actor to, Context data) throws IOException, NoSuchAlgorithmException { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox()); + URI inbox = uriComponentsBuilder.build().toUri(); + Instant now = Instant.now(); + String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + String host = inbox.getPort() > 0 ? String.format("%s:%d", inbox.getHost(), inbox.getPort()) : inbox.getHost(); + var finalContext = Context.build(data); + var payload = jsonMapper.writeValueAsString(finalContext); + final byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload.getBytes()); // (1) + final String digestHeader = "SHA-256=" + new String(Base64.encodeBase64(digest)); + String signatureString = signatureService.addSignature(from, host, "post", inbox.getPath(), requestDate, + digestHeader); + + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Content-Type", Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE); + requestHeaders.add("Date", requestDate); + requestHeaders.add("Host", host); + requestHeaders.add("Digest", digestHeader); + requestHeaders.add("Signature", signatureString); + HttpEntity request = new HttpEntity<>(payload, requestHeaders); + logger.debug("Sending context to {}: {}", to.getId(), payload); + ResponseEntity response = restClient.postForEntity(inbox, request, String.class); + logger.debug("Remote response: {} {}", response.getStatusCode(), response.getBody()); + return response.getStatusCode(); + } + + public User verifyActor(String method, String path, Map headers) { + String signatureString = headers.get("signature"); + if (StringUtils.isNotEmpty(signatureString)) { + try { + Signature signature = Signature.fromString(signatureString); + var keyId = UriComponentsBuilder.fromUriString(signature.getKeyId()).fragment(null).build().toUri(); + var user = getUserByAccountUri(keyId.toASCIIString()); + Key key = null; + Actor actor = null; + if (!user.isAnonymous()) { + // local user + key = keystoreManager.getPublicKey(); + } else { + var context = get(keyId); + if (context.isPresent()) { + actor = (Actor) context.get(); + key = KeystoreManager.publicKeyOf(actor); + } + } + if (key != null) { + Verifier verifier = new Verifier(key, signature); + try { + boolean result = verifier.verify(method.toLowerCase(), path, headers); + if (result) { + if (!user.isAnonymous()) { + return user; + } else { + if (actor != null) { + User person = new User(); + person.setUri(URI.create(actor.getId())); + if (actor.isSuspended()) { + logger.info("{} is suspended, deleting", actor.getId()); + applicationEventPublisher + .publishEvent(new DeleteUserEvent(this, actor.getId())); + } + return person; + } + } + } + } catch (NoSuchAlgorithmException | SignatureException | MissingRequiredHeaderException + | IOException e) { + logger.warn("Verification error for {}: {}", signature.getKeyId(), e.getMessage()); + } + } + } catch (Exception ex) { + + } + } + return AnonymousUser.INSTANCE; + } } -- cgit v1.2.3