dev.fitko.fitconnect.core.routing.RouteVerifier Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of client Show documentation
Show all versions of client Show documentation
Library that provides client access to the FIT-Connect api-endpoints for sending, subscribing and
routing
package dev.fitko.fitconnect.core.routing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import dev.fitko.fitconnect.api.domain.model.route.Route;
import dev.fitko.fitconnect.api.domain.model.route.RouteDestination;
import dev.fitko.fitconnect.api.domain.validation.ValidationResult;
import dev.fitko.fitconnect.api.exceptions.internal.InvalidKeyException;
import dev.fitko.fitconnect.api.exceptions.internal.RestApiException;
import dev.fitko.fitconnect.api.exceptions.internal.ValidationException;
import dev.fitko.fitconnect.api.services.keys.KeyService;
import dev.fitko.fitconnect.api.services.routing.RoutingVerificationService;
import dev.fitko.fitconnect.api.services.validation.ValidationService;
import dev.fitko.fitconnect.core.utils.Strings;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.error;
import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.ok;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.toList;
public class RouteVerifier implements RoutingVerificationService {
private static final JsonMapper MAPPER = getConfiguredJsonMapper();
private final KeyService keyService;
private final ValidationService validationService;
public RouteVerifier(final KeyService keyService, final ValidationService validationService) {
this.keyService = keyService;
this.validationService = validationService;
}
@Override
public ValidationResult validateRouteDestinations(final List routes, final String requestedServiceIdentifier, final String requestedRegion) {
return routes.stream()
.map(route -> validateRoute(route, requestedServiceIdentifier, requestedRegion))
.filter(ValidationResult::hasError)
.findFirst().orElse(ValidationResult.ok());
}
private ValidationResult validateRoute(final Route route, final String requestedServiceIdentifier, final String requestedRegion) {
try {
validateDestinationSignature(route, requestedServiceIdentifier, requestedRegion);
validateDestinationParameterSignature(route);
return ok();
} catch (final ValidationException e) {
return error(e);
} catch (final InvalidKeyException e) {
return error(new ValidationException("Public signature key is invalid: " + e.getCause().getMessage()));
} catch (final RestApiException e) {
return error(new ValidationException("Could not retrieve public signature key: " + e.getMessage()));
} catch (final ParseException | JOSEException | JsonProcessingException e) {
return error(new ValidationException("Signature processing failed: " + e.getMessage()));
}
}
private void validateDestinationParameterSignature(final Route route) throws JOSEException, ParseException, JsonProcessingException {
final SignedJWT completedSignature = combineDetachedSignatureWithPayload(route);
final RSAKey publicSignatureKey = loadPublicKey(route, completedSignature);
if (!completedSignature.verify(new RSASSAVerifier(publicSignatureKey))) {
throw new ValidationException("Invalid destination parameter signature for route " + route.getDestinationId());
}
checkHeaderAlgorithm(completedSignature.getHeader());
}
private RSAKey loadPublicKey(final Route route, final SignedJWT completedSignature) {
final String keyId = completedSignature.getHeader().getKeyID();
final String submissionUrl = route.getDestinationParameters().getSubmissionUrl();
return keyService.getWellKnownKeysForSubmissionUrl(submissionUrl, keyId);
}
private SignedJWT combineDetachedSignatureWithPayload(final Route route) throws ParseException, JsonProcessingException {
final SignedJWT detachedSignature = SignedJWT.parse(route.getDestinationParametersSignature());
final Base64URL encodedDetachedPayloadPart = getBase64EncodedDetachedPayload(route);
final Base64URL headerPart = detachedSignature.getHeader().getParsedBase64URL();
final Base64URL signaturePart = detachedSignature.getSignature();
return new SignedJWT(headerPart, encodedDetachedPayloadPart, signaturePart);
}
private Base64URL getBase64EncodedDetachedPayload(final Route route) throws JsonProcessingException {
final RouteDestination detachedPayload = route.getDestinationParameters();
final String cleanedDetachedPayload = Strings.cleanNonPrintableChars(MAPPER.writeValueAsString(detachedPayload));
// FIXME email vs. eMail difference between DVDV and SubmissionAPI -> https://git.fitko.de/fit-connect/planning/-/issues/601
return Base64URL.encode(cleanedDetachedPayload.replace("eMail", "email").getBytes(StandardCharsets.UTF_8));
}
private void validateDestinationSignature(final Route route, final String requestedServiceIdentifier, final String requestedRegion) throws ParseException, JOSEException, JsonProcessingException {
final SignedJWT signature = SignedJWT.parse(route.getDestinationSignature());
final JWSHeader header = signature.getHeader();
final JWTClaimsSet claims = signature.getJWTClaimsSet();
final String submissionUrl = route.getDestinationParameters().getSubmissionUrl();
checkHeaderAlgorithm(header);
validatePayloadSchema(claims);
checkMatchingSubmissionHost(claims, submissionUrl);
checkExpectedServices(claims, requestedServiceIdentifier, requestedRegion);
validateAgainstPublicKey(signature, header.getKeyID());
}
private void validatePayloadSchema(final JWTClaimsSet claims) {
final ValidationResult validationResult = validationService.validateDestinationSchema(claims.toJSONObject());
if (validationResult.hasError()) {
throw new ValidationException(validationResult.getError().getMessage(), validationResult.getError());
}
}
private void validateAgainstPublicKey(final SignedJWT signature, final String keyId) throws JOSEException {
final RSAKey portalPublicKey = keyService.getPortalPublicKey(keyId);
if (!signature.verify(new RSASSAVerifier(portalPublicKey))) {
throw new ValidationException("Invalid destination signature for public key id " + keyId);
}
}
private void checkExpectedServices(final JWTClaimsSet claims, final String requestedServiceIdentifier, final String requestedRegion) throws ParseException {
final List services = claims.getListClaim("services").stream()
.map(service -> new RouteService((Map>) service))
.collect(toList());
final var serviceId = getIdFromIdentifier(requestedServiceIdentifier);
if (services.stream().noneMatch(service -> service.hasMatchingService(serviceId))) {
throw new ValidationException("Requested service identifier '" + requestedServiceIdentifier + "' is not supported by any of the destinations services");
}
// check combination of service and region - ars can be null if the requested region is an areaId or ags
if (requestedRegion != null) {
final var regionId = getIdFromIdentifier(requestedRegion);
if (services.stream().noneMatch(service -> service.hasMatchingRegionAndService(regionId, serviceId))) {
throw new ValidationException("Requested region '" + requestedRegion + "' does not match any service provided by the destination");
}
}
}
private static String getIdFromIdentifier(final String identifier) {
if (isNumericId(identifier)) {
return identifier;
}
return Arrays.stream(identifier.split(":")).reduce((first, second) -> second).orElse(null);
}
private static boolean isNumericId(final String identifier) {
return Pattern.compile("\\d+").matcher(identifier).matches();
}
private void checkHeaderAlgorithm(final JWSHeader header) {
if (!header.getAlgorithm().equals(JWSAlgorithm.PS512)) {
throw new ValidationException("Algorithm in signature header is not " + JWSAlgorithm.PS512);
}
}
private void checkMatchingSubmissionHost(final JWTClaimsSet claims, final String submissionUrl) throws ParseException {
final String submissionHostClaim = claims.getStringClaim("submissionHost");
final String submissionUrlHost = getHostFromSubmissionUrl(submissionUrl);
if (!submissionUrlHost.equals(submissionHostClaim)) {
throw new ValidationException("Submission host does not match destinationParameters submission url " + submissionHostClaim);
}
}
private static String getHostFromSubmissionUrl(final String submissionUrl) {
if (submissionUrl == null) {
throw new ValidationException("SubmissionUrl must not be null");
}
try {
return URI.create(submissionUrl).getHost();
} catch (final IllegalArgumentException e) {
throw new ValidationException("SubmissionUrl could not be parsed", e);
}
}
private static JsonMapper getConfiguredJsonMapper() {
return JsonMapper.builder()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.INDENT_OUTPUT, false)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
}
static class RouteService {
private final List regionIds;
private final List serviceIds;
protected RouteService(final Map> service) {
regionIds = service.getOrDefault("gebietIDs", emptyList()).stream().map(RouteVerifier::getIdFromIdentifier).collect(toList());
serviceIds = service.getOrDefault("leistungIDs", emptyList()).stream().map(RouteVerifier::getIdFromIdentifier).collect(toList());
}
public boolean hasMatchingRegionAndService(final String regionId, final String serviceId) {
return hasMatchingRegion(regionId) && hasMatchingService(serviceId);
}
public boolean hasMatchingRegion(final String regionId) {
return regionIds.stream().anyMatch(regionId::contains);
}
public boolean hasMatchingService(final String serviceId) {
return serviceIds.contains(serviceId);
}
}
}