diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/config/WSBaseConfig.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/config/WSBaseConfig.java index 39f55ce6..df059418 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/config/WSBaseConfig.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/config/WSBaseConfig.java @@ -34,6 +34,16 @@ import org.dpppt.backend.sdk.ws.controller.DPPPTController; import org.dpppt.backend.sdk.ws.controller.GaenController; import org.dpppt.backend.sdk.ws.filter.ResponseWrapperFilter; +import org.dpppt.backend.sdk.ws.insertmanager.InsertManager; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.AssertKeyFormat; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.EnforceMatchingJWTClaimsForExposed; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.EnforceMatchingJWTClaimsForExposedNextDay; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.EnforceRetentionPeriod; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.EnforceValidRollingPeriod; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.RemoveFakeKeys; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.RemoveKeysFromFuture; +import org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier.IOSLegacyProblemRPLT144Modifier; +import org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier.OldAndroid0RPModifier; import org.dpppt.backend.sdk.ws.interceptor.HeaderInjector; import org.dpppt.backend.sdk.ws.security.KeyVault; import org.dpppt.backend.sdk.ws.security.NoValidateRequest; @@ -45,6 +55,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; @@ -203,6 +214,61 @@ public ProtoSignature gaenSigner() { } } + @Bean + public InsertManager insertManagerExposed() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new AssertKeyFormat(gaenValidationUtils())); + manager.addFilter(new EnforceMatchingJWTClaimsForExposed(gaenRequestValidator)); + manager.addFilter(new RemoveKeysFromFuture()); + manager.addFilter(new EnforceRetentionPeriod(gaenValidationUtils())); + manager.addFilter(new RemoveFakeKeys()); + manager.addFilter(new EnforceValidRollingPeriod()); + return manager; + } + + @Bean + public InsertManager insertManagerExposedNextDay() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new AssertKeyFormat(gaenValidationUtils())); + manager.addFilter(new EnforceMatchingJWTClaimsForExposedNextDay(gaenValidationUtils())); + manager.addFilter(new RemoveKeysFromFuture()); + manager.addFilter(new EnforceRetentionPeriod(gaenValidationUtils())); + manager.addFilter(new RemoveFakeKeys()); + manager.addFilter(new EnforceValidRollingPeriod()); + return manager; + } + + /** + * Even though there are probably no android devices left that send TEKs with rollingPeriod of 0, + * this modifier will not hurt. Every TEK with rollingPeriod of 0 will be reported. + */ + @ConditionalOnProperty( + value = "ws.app.gaen.insertmanager.android0rpmodifier", + havingValue = "true", + matchIfMissing = false) + @Bean + public OldAndroid0RPModifier oldAndroid0RPModifier(InsertManager manager) { + var androidModifier = new OldAndroid0RPModifier(); + manager.addModifier(androidModifier); + return androidModifier; + } + + /** + * This modifier will most probably not be enabled, as there should be very little iOS devices + * left that cannot handle a non-144 rollingPeriod key. Also, up to 8th of September 2020, Android + * did not release same day keys. + */ + @ConditionalOnProperty( + value = "ws.app.gaen.insertmanager.iosrplt144modifier", + havingValue = "true", + matchIfMissing = false) + @Bean + public IOSLegacyProblemRPLT144Modifier iosLegacyProblemRPLT144(InsertManager manager) { + var iosModifier = new IOSLegacyProblemRPLT144Modifier(); + manager.addModifier(iosModifier); + return iosModifier; + } + @Bean public DPPPTController dppptSDKController() { ValidateRequest theValidator = requestValidator; @@ -237,6 +303,8 @@ public GaenController gaenController() { theValidator = backupValidator(); } return new GaenController( + insertManagerExposed(), + insertManagerExposedNextDay(), gaenDataService(), fakeKeyService(), theValidator, diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DPPPTController.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DPPPTController.java index e26da265..0c12991b 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DPPPTController.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DPPPTController.java @@ -116,25 +116,25 @@ public DPPPTController( + " + OS-Version", example = "ch.ubique.android.starsdk;1.0;iOS;13.3") String userAgent, - @AuthenticationPrincipal Object principal) { + @AuthenticationPrincipal Object principal) + throws InvalidDateException, WrongScopeException, ClaimIsBeforeOnsetException { var now = UTCInstant.now(); - long keyDate; - try { - if (!this.validateRequest.isValid(principal)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - keyDate = this.validateRequest.validateKeyDate(now, principal, exposeeRequest); - } catch (WrongScopeException | ClaimIsBeforeOnsetException | InvalidDateException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + if (!this.validateRequest.isValid(principal)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - - if (!validationUtils.isValidBase64Key(exposeeRequest.getKey())) { + if (!validationUtils.isValidKeyFormat(exposeeRequest.getKey())) { return new ResponseEntity<>("No valid base64 key", HttpStatus.BAD_REQUEST); } // TODO: should we give that information? Exposee exposee = new Exposee(); exposee.setKey(exposeeRequest.getKey()); + long keyDate; + try { + keyDate = this.validateRequest.validateKeyDate(now, principal, exposeeRequest); + } catch (ClaimIsBeforeOnsetException | InvalidDateException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } exposee.setKeyDate(keyDate); if (!this.validateRequest.isFakeRequest(principal, exposeeRequest)) { @@ -186,7 +186,7 @@ public DPPPTController( List exposees = new ArrayList<>(); for (var exposedKey : exposeeRequests.getExposedKeys()) { - if (!validationUtils.isValidBase64Key(exposedKey.getKey())) { + if (!validationUtils.isValidKeyFormat(exposedKey.getKey())) { return new ResponseEntity<>("No valid base64 key", HttpStatus.BAD_REQUEST); } diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DebugController.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DebugController.java index 6dd653aa..cf842f0d 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DebugController.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/DebugController.java @@ -71,7 +71,7 @@ public DebugController( List nonFakeKeys = new ArrayList<>(); for (var key : gaenRequest.getGaenKeys()) { - if (!validationUtils.isValidBase64Key(key.getKeyData())) { + if (!validationUtils.isValidKeyFormat(key.getKeyData())) { return new ResponseEntity<>("No valid base64 key", HttpStatus.BAD_REQUEST); } this.validateRequest.validateKeyDate(now, principal, key); diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/GaenController.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/GaenController.java index ab98a243..9e6931ed 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/GaenController.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/controller/GaenController.java @@ -27,18 +27,24 @@ import org.dpppt.backend.sdk.data.gaen.FakeKeyService; import org.dpppt.backend.sdk.data.gaen.GAENDataService; import org.dpppt.backend.sdk.model.gaen.DayBuckets; -import org.dpppt.backend.sdk.model.gaen.GaenKey; import org.dpppt.backend.sdk.model.gaen.GaenRequest; import org.dpppt.backend.sdk.model.gaen.GaenSecondDay; import org.dpppt.backend.sdk.model.gaen.GaenUnit; import org.dpppt.backend.sdk.utils.DurationExpiredException; import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.InsertException; +import org.dpppt.backend.sdk.ws.insertmanager.InsertManager; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.AssertKeyFormat.KeyFormatException; import org.dpppt.backend.sdk.ws.security.ValidateRequest; +import org.dpppt.backend.sdk.ws.security.ValidateRequest.ClaimIsBeforeOnsetException; import org.dpppt.backend.sdk.ws.security.ValidateRequest.InvalidDateException; +import org.dpppt.backend.sdk.ws.security.ValidateRequest.WrongScopeException; import org.dpppt.backend.sdk.ws.security.signature.ProtoSignature; import org.dpppt.backend.sdk.ws.security.signature.ProtoSignature.ProtoSignatureWrapper; import org.dpppt.backend.sdk.ws.util.ValidationUtils; import org.dpppt.backend.sdk.ws.util.ValidationUtils.BadBatchReleaseTimeException; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateClaimIsMissing; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateIsInvalid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; @@ -79,6 +85,8 @@ public class GaenController { private final Duration requestTime; private final ValidateRequest validateRequest; private final ValidationUtils validationUtils; + private final InsertManager insertManagerExposed; + private final InsertManager insertManagerExposedNextDay; private final GAENDataService dataService; private final FakeKeyService fakeKeyService; private final Duration exposedListCacheControl; @@ -86,6 +94,8 @@ public class GaenController { private final ProtoSignature gaenSigner; public GaenController( + InsertManager insertManagerExposed, + InsertManager insertManagerExposedNextDay, GAENDataService dataService, FakeKeyService fakeKeyService, ValidateRequest validateRequest, @@ -95,6 +105,8 @@ public GaenController( Duration requestTime, Duration exposedListCacheControl, PrivateKey secondDayKey) { + this.insertManagerExposed = insertManagerExposed; + this.insertManagerExposedNextDay = insertManagerExposedNextDay; this.dataService = dataService; this.fakeKeyService = fakeKeyService; this.releaseBucketDuration = releaseBucketDuration; @@ -113,10 +125,7 @@ public GaenController( + " to the current day's exposed key", responses = { "200=>The exposed keys have been stored in the database", - "400=> " - + "- Invalid base64 encoding in GaenRequest" - + "- negative rolling period" - + "- fake claim with non-fake keys", + "400=>Invalid base64 encoding in GaenRequest", "403=>Authentication failed" }) public @ResponseBody Callable> addExposed( @@ -137,69 +146,18 @@ public GaenController( String userAgent, @AuthenticationPrincipal @Documentation(description = "JWT token that can be verified by the backend server") - Object principal) { + Object principal) + throws DelayedKeyDateIsInvalid, InsertException, WrongScopeException { var now = UTCInstant.now(); - boolean valid = false; - try { - valid = this.validateRequest.isValid(principal); - } catch (ValidateRequest.WrongScopeException e) { - logger.error("Got invalid scope: " + e); - } - if (!valid) { - return () -> ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - List nonFakeKeys = new ArrayList<>(); - for (var key : gaenRequest.getGaenKeys()) { - if (!validationUtils.isValidBase64Key(key.getKeyData())) { - return () -> new ResponseEntity<>("No valid base64 key", HttpStatus.BAD_REQUEST); - } - if (this.validateRequest.isFakeRequest(principal, key) - || hasNegativeRollingPeriod(key) - || hasInvalidKeyDate(now, principal, key)) { - continue; - } + this.validateRequest.isValid(principal); - if (key.getRollingPeriod().equals(0)) { - // Additionally to delaying keys this feature also makes sure the rolling period is always - // set - // to 144 to make sure iOS 13.5.x does not ignore the TEK. - // Currently only Android seems to send 0 which can never be valid, since a non used key - // should not be submitted. - // This allows to check for the Google-TEKs also on iOS. Because the Rolling Proximity - // Identifier is based on the TEK and the unix epoch, this should work. The only downside is - // that iOS - // will not be able to optimize verification of the TEKs, because it will have to consider - // each - // TEK for a whole day. - logger.error("RollingPeriod should NOT be 0, fixing it and using 144"); - key.setRollingPeriod(GaenKey.GaenKeyDefaultRollingPeriod); + // Filter out non valid keys and insert them into the database (c.f. InsertManager and + // configured Filters in the WSBaseConfig) + insertManagerExposed.insertIntoDatabase(gaenRequest.getGaenKeys(), userAgent, principal, now); - // If this is a same day TEK we are delaying its release - nonFakeKeys.add(key); - } - nonFakeKeys.add(key); - } - - if (principal instanceof Jwt - && ((Jwt) principal).containsClaim("fake") - && ((Jwt) principal).getClaim("fake").equals("1") - && !nonFakeKeys.isEmpty()) { - return () -> - ResponseEntity.badRequest().body("Claim is fake but list contains non fake keys"); - } - if (!nonFakeKeys.isEmpty()) { - dataService.upsertExposees(nonFakeKeys, now); - } - - var delayedKeyDateUTCInstant = - UTCInstant.of(gaenRequest.getDelayedKeyDate(), GaenUnit.TenMinutes); - if (delayedKeyDateUTCInstant.isBeforeDateOf(now.getLocalDate().minusDays(1)) - || delayedKeyDateUTCInstant.isAfterDateOf(now.getLocalDate().plusDays(1))) { - return () -> - ResponseEntity.badRequest() - .body("delayedKeyDate date must be between yesterday and tomorrow"); - } + this.validationUtils.assertDelayedKeyDate( + now, UTCInstant.of(gaenRequest.getDelayedKeyDate(), GaenUnit.TenMinutes)); var responseBuilder = ResponseEntity.ok(); if (principal instanceof Jwt) { @@ -240,8 +198,7 @@ public GaenController( "200=>The exposed key has been stored in the backend", "400=>" + "- Ivnalid base64 encoded Temporary Exposure Key" - + "- TEK-date does not match delayedKeyDAte claim in Jwt" - + "- TEK has negative rolling period", + + "- TEK-date does not match delayedKeyDAte claim in Jwt", "403=>No delayedKeyDate claim in authentication" }) public @ResponseBody Callable> addExposedSecond( @@ -259,44 +216,18 @@ public GaenController( description = "JWT token that can be verified by the backend server, must have been created by" + " /v1/gaen/exposed and contain the delayedKeyDate") - Object principal) { + Object principal) + throws DelayedKeyDateClaimIsMissing, InsertException { var now = UTCInstant.now(); - if (!validationUtils.isValidBase64Key(gaenSecondDay.getDelayedKey().getKeyData())) { - return () -> new ResponseEntity<>("No valid base64 key", HttpStatus.BAD_REQUEST); - } - if (principal instanceof Jwt && !((Jwt) principal).containsClaim("delayedKeyDate")) { - return () -> - ResponseEntity.status(HttpStatus.FORBIDDEN).body("claim does not contain delayedKeyDate"); - } - if (principal instanceof Jwt) { - var jwt = (Jwt) principal; - var claimKeyDate = Integer.parseInt(jwt.getClaimAsString("delayedKeyDate")); - if (!gaenSecondDay.getDelayedKey().getRollingStartNumber().equals(claimKeyDate)) { - return () -> ResponseEntity.badRequest().body("keyDate does not match claim keyDate"); - } - } + // Throws an exception if the claim doesn't exist. The actual verification is done in the + // filters. + validationUtils.getDelayedKeyDateClaim(principal); - if (!this.validateRequest.isFakeRequest(principal, gaenSecondDay.getDelayedKey())) { - if (gaenSecondDay.getDelayedKey().getRollingPeriod().equals(0)) { - // currently only android seems to send 0 which can never be valid, since a non used key - // should not be submitted - // default value according to EN is 144, so just set it to that. If we ever get 0 from iOS - // we should log it, since - // this should not happen - gaenSecondDay.getDelayedKey().setRollingPeriod(GaenKey.GaenKeyDefaultRollingPeriod); - if (userAgent.toLowerCase().contains("ios")) { - logger.error("Received a rolling period of 0 for an iOS User-Agent"); - } - } else if (gaenSecondDay.getDelayedKey().getRollingPeriod() < 0) { - return () -> - ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body("Rolling Period MUST NOT be negative."); - } - List keys = new ArrayList<>(); - keys.add(gaenSecondDay.getDelayedKey()); - dataService.upsertExposees(keys, now); - } + // Filter out non valid keys and insert them into the database (c.f. InsertManager and + // configured Filters in the WSBaseConfig) + insertManagerExposedNextDay.insertIntoDatabase( + List.of(gaenSecondDay.getDelayedKey()), userAgent, principal, now); return () -> { try { @@ -335,19 +266,19 @@ public GaenController( Long publishedafter) throws BadBatchReleaseTimeException, IOException, InvalidKeyException, SignatureException, NoSuchAlgorithmException { - var utcNow = UTCInstant.now(); + var now = UTCInstant.now(); if (!validationUtils.isValidKeyDate(UTCInstant.ofEpochMillis(keyDate))) { return ResponseEntity.notFound().build(); } if (publishedafter != null && !validationUtils.isValidBatchReleaseTime( - UTCInstant.ofEpochMillis(publishedafter), utcNow)) { + UTCInstant.ofEpochMillis(publishedafter), now)) { return ResponseEntity.notFound().build(); } - long now = utcNow.getTimestamp(); // calculate exposed until bucket - long publishedUntil = now - (now % releaseBucketDuration.toMillis()); + long publishedUntil = + now.getTimestamp() - (now.getTimestamp() % releaseBucketDuration.toMillis()); var exposedKeys = dataService.getSortedExposedForKeyDate(keyDate, publishedafter, publishedUntil); @@ -403,24 +334,17 @@ public GaenController( return ResponseEntity.ok(dayBuckets); } - private boolean hasNegativeRollingPeriod(GaenKey key) { - Integer rollingPeriod = key.getRollingPeriod(); - if (key.getRollingPeriod() < 0) { - logger.error("Detected key with negative rolling period {}", rollingPeriod); - return true; - } else { - return false; - } + @ExceptionHandler({DelayedKeyDateClaimIsMissing.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity delayedClaimIsWrong() { + return ResponseEntity.badRequest().body("DelayedKeyDateClaim is wrong"); } - private boolean hasInvalidKeyDate(UTCInstant now, Object principal, GaenKey key) { - try { - this.validateRequest.validateKeyDate(now, principal, key); - } catch (InvalidDateException | ValidateRequest.ClaimIsBeforeOnsetException e) { - logger.error(e.getLocalizedMessage()); - return true; - } - return false; + @ExceptionHandler({DelayedKeyDateIsInvalid.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity delayedKeyDateIsInvalid() { + return ResponseEntity.badRequest() + .body("DelayedKeyDate must be between yesterday and tomorrow"); } @ExceptionHandler({ @@ -429,10 +353,18 @@ private boolean hasInvalidKeyDate(UTCInstant now, Object principal, GaenKey key) JsonProcessingException.class, MethodArgumentNotValidException.class, BadBatchReleaseTimeException.class, - DateTimeParseException.class + DateTimeParseException.class, + ClaimIsBeforeOnsetException.class, + KeyFormatException.class }) @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity invalidArguments() { return ResponseEntity.badRequest().build(); } + + @ExceptionHandler({WrongScopeException.class}) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity forbidden() { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } } diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/filter/README.md b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/filter/README.md new file mode 100644 index 00000000..e69de29b diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertException.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertException.java new file mode 100644 index 00000000..8c8f54e7 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertException.java @@ -0,0 +1,7 @@ +package org.dpppt.backend.sdk.ws.insertmanager; + +public abstract class InsertException extends Exception { + + /** */ + private static final long serialVersionUID = 6476089262577182680L; +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManager.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManager.java new file mode 100644 index 00000000..22d31582 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManager.java @@ -0,0 +1,123 @@ +package org.dpppt.backend.sdk.ws.insertmanager; + +import java.util.ArrayList; +import java.util.List; +import org.dpppt.backend.sdk.data.gaen.GAENDataService; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.KeyInsertionFilter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier.KeyInsertionModifier; +import org.dpppt.backend.sdk.ws.util.ValidationUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The insertion manager is responsible for inserting keys uploaded by clients into the database. To + * make sure we only have valid keys in the database, a list of {@link KeyInsertionModifier} is + * applied, and then a list of {@Link KeyInsertionFilter} is applied to the given list of keys. The + * remaining keys are then inserted into the database. If any of the modifiers filters throws an + * {@Link InsertException} the process of insertions is aborted and the exception is propagated back + * to the caller, which is responsible for handling the exception. + */ +public class InsertManager { + + private final List filterList = new ArrayList<>(); + private final List modifierList = new ArrayList<>(); + + private final GAENDataService dataService; + private final ValidationUtils validationUtils; + + private static final Logger logger = LoggerFactory.getLogger(InsertManager.class); + + public InsertManager(GAENDataService dataService, ValidationUtils validationUtils) { + this.dataService = dataService; + this.validationUtils = validationUtils; + } + + public void addFilter(KeyInsertionFilter filter) { + this.filterList.add(filter); + } + + public void addModifier(KeyInsertionModifier modifier) { + this.modifierList.add(modifier); + } + + /** + * Inserts the keys into the database. The additional parameters are supplied to the configured + * modifiers and filters. + * + * @param keys the list of keys from the client + * @param header request header from client + * @param principal key upload authorization, for example a JWT token. + * @param now current timestamp to work with. + * @throws InsertException filters are allowed to throw errors, for example to signal client + * errors in the key upload + */ + public void insertIntoDatabase( + List keys, String header, Object principal, UTCInstant now) throws InsertException { + + if (keys == null || keys.isEmpty()) { + return; + } + + var internalKeys = keys; + var headerParts = header.split(";"); + if (headerParts.length != 5) { + headerParts = + List.of("org.example.dp3t", "1.0.0", "0", "Android", "29").toArray(new String[0]); + logger.error("We received an invalid header, setting default."); + } + + // Map the given headers to os type, os version and app version. Examples are: + // ch.admin.bag.dp36;1.0.7;200724.1105.215;iOS;13.6 + // ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29 + var osType = exctractOS(headerParts[3]); + var osVersion = extractOsVersion(headerParts[4]); + var appVersion = extractAppVersion(headerParts[1], headerParts[2]); + + for (KeyInsertionModifier modifier : modifierList) { + internalKeys = modifier.modify(now, internalKeys, osType, osVersion, appVersion, principal); + } + + for (KeyInsertionFilter filter : filterList) { + internalKeys = filter.filter(now, internalKeys, osType, osVersion, appVersion, principal); + } + + // if no keys remain or this is a fake request, just return. Else, insert the + // remaining keys. + if (internalKeys.isEmpty() || validationUtils.jwtIsFake(principal)) { + return; + } else { + dataService.upsertExposees(internalKeys, now); + } + } + + /** + * Extracts the {@link OSType} from the osString that is given by the client request. + * + * @param osString + * @return + */ + private OSType exctractOS(String osString) { + var result = OSType.ANDROID; + switch (osString.toLowerCase()) { + case "ios": + result = OSType.IOS; + break; + case "android": + break; + default: + result = OSType.ANDROID; + } + return result; + } + + private Version extractOsVersion(String osVersionString) { + return new Version(osVersionString); + } + + private Version extractAppVersion(String osAppVersionString, String osMetaInfo) { + return new Version(osAppVersionString + "+" + osMetaInfo); + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/OSType.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/OSType.java new file mode 100644 index 00000000..c910d4e9 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/OSType.java @@ -0,0 +1,18 @@ +package org.dpppt.backend.sdk.ws.insertmanager; + +public enum OSType { + ANDROID, + IOS; + + @Override + public String toString() { + switch (this) { + case ANDROID: + return "Android"; + case IOS: + return "iOS"; + default: + return "Unknown"; + } + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README.md b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README.md new file mode 100644 index 00000000..0ed9849c --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README.md @@ -0,0 +1,183 @@ +# Insert-Manager + +The Insert-Manager is used to reduce logic in the controllers. +It provides a second abstraction layer next to the `DataServices` to provide generic validation and normalization. +Encapsulating the logic into smaller pieces of code allows for easier and better reviews of the respective +filters and modifiers. +Furthermore, each filter or modifier can be documented individually, without having to document each place where it +is applied. + +The insert-manager uses two lists: `modifiers` and `filters`. +First the modifies are run on the keys, then the filters, in the order as they're given to the `InsertManager` + +```text + +Mobile Client -> Backend -> InsertManager ( Modifiers -> Filters ) -> Database + +``` + +The Insert-Manager holds a list of `KeyInsertionFilter`, which provide code to filter for invalid data. +Each filter can decide to either skip respective keys, or throw an `InsertException`. +Throwing an exception aborts the current insert request, and the exception is bubbled up to the controller. +Inside the controller the exception can be mapped to a specific error message and an HTTP status code. + +Additionally the Insert-Manager can be configured to hold a list of `KeyInsertModifier`. +Modifiers can modify incoming keys before inserting them into the database, for example to fix buggy clients. + + +## Valid Keys + +A valid key is defined as follows: +- Base64 Encoded key with correct length of 32 bytes +- Non Fake +- Rolling Period in [1..144] +- Rolling start number inside the configured retention period +- Rolling start number not too far in the future, more precisely not after the day after tomorrow at time of insertion +- Key date must honor the onset date which is given by the health authority + + +## KeyInsertionFilter Interface + +The `KeyInsertionFilter` interface has the following signature: + +```java +public interface KeyInsertionFilter { + List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException; +} +``` + +It gets a `now` object representing _the time the request started_ from the controller , a list of keys, some OS and app related information taken from the `UserAgent` (c.f. `InsertManager@exctractOS` and following) and a possible principal object, representing a authenticated state (e.g. a `JWT`). The function is marked to throw a `InsertException` to stop the inserting process. + + +## KeyInsertionModifier Interface + +The `KeyInsertionModifier` interface has the following signature: + +```java +public interface KeyInsertionModifier { + List modify( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException; +} +``` + +It gets a `now` object representing _the time the request started_ from the controller , a list of keys, some OS and app related information taken from the `UserAgent` (c.f. `InsertManager@exctractOS` and following) and a possible principal object, representing a authenticated state (e.g. a `JWT`). The function is marked to throw a `InsertException` to stop the inserting process. + + +## Names + +The filters should be one of +- `Assert` - lets either pass all keys or throws `InsertException` +- `Remove` - explains which keys are removed +- `Enforce` - explains which keys are kept + + +## InsertException + +An `InsertException` can be thrown inside an implementation of the `InsertionFilter` interface to mark an insert as "unrecoverable" and abort it. Such "unrecoverable" states might be patterns in the uploaded model, which allows for packet sniffing and/or information gathering. + + +## Default Filters + +Looking at the `WSBaseConfig`, we can see that two instances of the `InsertManager` are constructed, one for the `exposed` request and one for the `exposedNextDay` request, both are supplied wit a set of default filters: + +```java +public abstract class WSBaseConfig implements SchedulingConfigurer, WebMvcConfigurer { + + // ... + + @Bean + public InsertManager insertManagerExposed() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new AssertKeyFormat(gaenValidationUtils())); + manager.addFilter(new EnforceMatchingJWTClaimsForExposed(gaenRequestValidator)); + manager.addFilter(new RemoveKeysFromFuture()); + manager.addFilter(new EnforceRetentionPeriod(gaenValidationUtils())); + manager.addFilter(new RemoveFakeKeys()); + manager.addFilter(new EnforceValidRollingPeriod()); + return manager; + } + + @Bean + public InsertManager insertManagerExposedNextDay() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new AssertKeyFormat(gaenValidationUtils())); + manager.addFilter(new EnforceMatchingJWTClaimsForExposedNextDay(gaenValidationUtils())); + manager.addFilter(new RemoveKeysFromFuture()); + manager.addFilter(new EnforceRetentionPeriod(gaenValidationUtils())); + manager.addFilter(new RemoveFakeKeys()); + manager.addFilter(new EnforceValidRollingPeriod()); + return manager; + } + +} +``` + +- `AssertKeyFormat` + > This filter validates that the key actually is a correctly encoded base64 string and has the correct length. Since + we are using 16 bytes of key data, those can be represented with exactly 24 characters. The validation of the + length is already done during model validation and is assumed to be correct when reaching the filter. This + filter _throws_ a `KeyFormatException` if any of the keys is wrongly encoded. Every key submitted _MUST_ have + correct base64 encoding and have the correct length. +- `EnforceMatchingJWTClaimsForExposed`: + > This filter compares the supplied keys with information found in the JWT token for the `exposed` request. It makes sure, that the onset date, which will be set by the health authority and inserted as a claim into the JWT is the lower bound for allowed key dates. +- `EnforceMatchingJWTClaimsForExposedNextDay`: + > This filter compares the supplied keys with information found in the JWT token for the `exposednextday` request. It makes sure, that the JWT contains the previously submitted and checked `delayedKeyDate`, which is compared to the actual supplied key. +- `RemoveKeysFromFuture`: + > Representing the maximum allowed time skew. Any key which is further in the future as the day after tomorrow is considered to be _maliciously_ or faulty (for example because of wrong date time settings) uploaded and is hence filtered out. +- `EnforceRetentionPeriod`: + > Only keys with key date in the configured retention period are inserted into the datbase. Any key which was valid earlier than `RetentionPeriod` is considered to be outdated and not saved in the database. The key would be removed during the next database clean anyways. +- `RemoveFakeKeys` + > Only keys that are non-fake are inserted into the database, more precicely keys that have the fake flag set to `0`. +- `EnforceValidRollingPeriod`: + > The `RollingPeriod` represents the 10 minutes interval of the key's validity. Negative numbers are not possible, hence any key having a negative rolling period is considered to be _maliciously_ uploaded. Further, according to [Apple/Googles documentation](https://github.com/google/exposure-notifications-server/blob/main/docs/server_functional_requirements.md) values must be in [1..144] + + +## Additonal Modifiers + +- `IOSLegacyProblemRPLT144FModifier` + > This modifier makes sure, that rolling period is always set to 144. Default value according to EN is 144, so just set it to that. This allows to check for the Google-TEKs also on iOS. Because the Rolling Proximity Identifier is based on the TEK and the unix epoch, this should work. The only downside is that iOS will not be able to optimize verification of the TEKs, because it will have to consider each TEK for a whole day. +- `OldAndroid0RPModifier`: + > Some early builds of Google's Exposure Notification API returned TEKs with rolling period set to '0'. According to the specification, this is invalid and will cause both Android and iOS to drop/ignore the key. To mitigate ignoring TEKs from these builds alltogether, the rolling period is increased to '144' (one full day). This should not happen anymore and can be removed in the near future. Until then we are going to log whenever this happens to be able to monitor this problem. + + +## Configuration + +During construction, instances of `GAENDataService` and `ValidationUtils` are needed. Further, any filter or modifier can be added to the list with `addFilter(KeyInsertionFilter filter)` or `addModifier(KeyInsertionModifier)`. Ideally, this happens inside the [`WSBaseConfig`](../config/WSBaseConfig.java), where default filters are added right after constructing the `InsertManager`. + +To allow for conditional `KeyInsertionFilters` or `KeyInsertionModifiers` refer to the following snippet: + +```java +public abstract class WSBaseConfig implements SchedulingConfigurer, WebMvcConfigurer { + + // ... + + @ConditionalOnProperty( + value = "ws.app.gaen.insertmanager.iosrplt144modifier", + havingValue = "true", + matchIfMissing = false) + @Bean + public IOSLegacyProblemRPLT144Modifier iosLegacyProblemRPLT144(InsertManager manager) { + var iosModifier = new IOSLegacyProblemRPLT144Modifier(); + manager.addModifier(iosModifier); + return iosModifier; + } +} +``` + +This looks for a property, either supplied via a `application.properties` file, or via `java` arguments (e.g. `java -D ws.app.gaen.insertmanager.iosrplt144modifier`) and constructs and inserts the respective modifier bean into the modifier chain. For further `SpringBoot` `Conditional` annotations have a look at ["Spring Boot Conditionals"](https://reflectoring.io/spring-boot-conditionals/) + +Encapsulating the logic into smaller pieces of code allows for easier and better reviews of the respective filters. +Further, for each filter an extensive documentation can be provided, without cluttering the code with too many comments. diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/AssertKeyFormat.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/AssertKeyFormat.java new file mode 100644 index 00000000..bd96b5e3 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/AssertKeyFormat.java @@ -0,0 +1,47 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.InsertException; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; +import org.dpppt.backend.sdk.ws.util.ValidationUtils; + +/** + * Rejects a batch of keys if any of them have an invalid base64 encoding or doesn't have the + * correct length. Invalid base64 encodings or wrong key lengths point to a client error. + */ +public class AssertKeyFormat implements KeyInsertionFilter { + + private final ValidationUtils validationUtils; + + public AssertKeyFormat(ValidationUtils validationUtils) { + this.validationUtils = validationUtils; + } + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException { + + var hasInvalidKeys = + content.stream().anyMatch(key -> !validationUtils.isValidKeyFormat(key.getKeyData())); + + if (hasInvalidKeys) { + throw new KeyFormatException(); + } + return content; + } + + public class KeyFormatException extends InsertException { + + /** */ + private static final long serialVersionUID = -918099046973553472L; + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposed.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposed.java new file mode 100644 index 00000000..b60a270b --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposed.java @@ -0,0 +1,47 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; +import org.dpppt.backend.sdk.ws.security.ValidateRequest; +import org.dpppt.backend.sdk.ws.security.ValidateRequest.ClaimIsBeforeOnsetException; +import org.dpppt.backend.sdk.ws.security.ValidateRequest.InvalidDateException; + +/** + * This filter compares the supplied keys from the exposed request with information found in the JWT + * token: the key dates must be >= the onset date, which was set by the health authority and is + * available as a claim in the JWT + */ +public class EnforceMatchingJWTClaimsForExposed implements KeyInsertionFilter { + + private final ValidateRequest validateRequest; + + public EnforceMatchingJWTClaimsForExposed(ValidateRequest validateRequest) { + this.validateRequest = validateRequest; + } + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream() + .filter(key -> isValidKeyDate(key, principal, now)) + .collect(Collectors.toList()); + } + + private boolean isValidKeyDate(GaenKey key, Object principal, UTCInstant now) { + try { + validateRequest.validateKeyDate(now, principal, key); + return true; + } catch (InvalidDateException | ClaimIsBeforeOnsetException es) { + return false; + } + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposedNextDay.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposedNextDay.java new file mode 100644 index 00000000..266aa43e --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceMatchingJWTClaimsForExposedNextDay.java @@ -0,0 +1,61 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.model.gaen.GaenUnit; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; +import org.dpppt.backend.sdk.ws.util.ValidationUtils; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateClaimIsMissing; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateIsInvalid; + +/** + * This filter compares the supplied keys from the exposed next day request with information found + * in the JWT token: the supplied key must match `delayedKeyDate`, which has been set as a claim by + * a previous call to `exposed` + */ +public class EnforceMatchingJWTClaimsForExposedNextDay implements KeyInsertionFilter { + + private final ValidationUtils validationUtils; + + public EnforceMatchingJWTClaimsForExposedNextDay(ValidationUtils utils) { + this.validationUtils = utils; + } + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream() + .filter( + key -> { + try { + // getDelayedKeyDateClaim throws an exception if there is no delayedKeyDate claim + // available. + var delayedKeyDateClaim = validationUtils.getDelayedKeyDateClaim(principal); + var delayedKeyDate = + UTCInstant.of(key.getRollingStartNumber(), GaenUnit.TenMinutes); + return delayedKeyDateClaim.equals(delayedKeyDate) + && isValidDelayedKeyDate(now, delayedKeyDate); + } catch (DelayedKeyDateClaimIsMissing ex) { + return false; + } + }) + .collect(Collectors.toList()); + } + + private boolean isValidDelayedKeyDate(UTCInstant now, UTCInstant delayedKeyDate) { + try { + validationUtils.assertDelayedKeyDate(now, delayedKeyDate); + return true; + } catch (DelayedKeyDateIsInvalid ex) { + return false; + } + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceRetentionPeriod.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceRetentionPeriod.java new file mode 100644 index 00000000..f7df3abe --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceRetentionPeriod.java @@ -0,0 +1,40 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.model.gaen.GaenUnit; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; +import org.dpppt.backend.sdk.ws.util.ValidationUtils; + +/** + * Checks if a key is in the configured retention period. If a key is before the retention period it + * is filtered out, as it will not be relevant for the system anymore. + */ +public class EnforceRetentionPeriod implements KeyInsertionFilter { + + private final ValidationUtils validationUtils; + + public EnforceRetentionPeriod(ValidationUtils validationUtils) { + this.validationUtils = validationUtils; + } + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream() + .filter( + key -> { + var timestamp = UTCInstant.of(key.getRollingStartNumber(), GaenUnit.TenMinutes); + return !validationUtils.isBeforeRetention(timestamp, now); + }) + .collect(Collectors.toList()); + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceValidRollingPeriod.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceValidRollingPeriod.java new file mode 100644 index 00000000..9eb7a8c3 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/EnforceValidRollingPeriod.java @@ -0,0 +1,30 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** + * This filter checks for valid rolling period. The rolling period must always be in [1..144], + * otherwise the key is not valid and is filtered out. See EN documentation + */ +public class EnforceValidRollingPeriod implements KeyInsertionFilter { + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream() + .filter(key -> key.getRollingPeriod() >= 1 && key.getRollingPeriod() <= 144) + .collect(Collectors.toList()); + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/KeyInsertionFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/KeyInsertionFilter.java new file mode 100644 index 00000000..7e385884 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/KeyInsertionFilter.java @@ -0,0 +1,36 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.InsertException; +import org.dpppt.backend.sdk.ws.insertmanager.InsertManager; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** Interface for filters than can be configured in the {@link InsertManager} */ +public interface KeyInsertionFilter { + + /** + * The {@link InsertManager} goes through all configured filters and calls them with a list of + * {@link GaenKey} where the filters are applied before inserting into the database. + * + * @param now current timestamp + * @param content the list of new gaen keys for insertion + * @param osType the os type of the client which uploaded the keys + * @param osVersion the os version of the client which uploaded the keys + * @param appVersion the app version of the client which uploaded the keys + * @param principal the authorization context which belongs to the uploaded keys. Depending on the + * configured system, this could be a JWT token for example. + * @return + * @throws InsertException + */ + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException; +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveFakeKeys.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveFakeKeys.java new file mode 100644 index 00000000..d184c1a5 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveFakeKeys.java @@ -0,0 +1,23 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** Keep only Non-Fake keys, so that fake keys are not stored in the database. */ +public class RemoveFakeKeys implements KeyInsertionFilter { + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream().filter(key -> key.getFake().equals(0)).collect(Collectors.toList()); + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveKeysFromFuture.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveKeysFromFuture.java new file mode 100644 index 00000000..ede8a4d9 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RemoveKeysFromFuture.java @@ -0,0 +1,33 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionfilters; + +import java.util.List; +import java.util.stream.Collectors; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.model.gaen.GaenUnit; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** + * Reject keys that are too far in the future. The `rollingStart` must not be later than tomorrow. + */ +public class RemoveKeysFromFuture implements KeyInsertionFilter { + + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + return content.stream() + .filter( + key -> { + var rollingStartNumberInstant = + UTCInstant.of(key.getRollingStartNumber(), GaenUnit.TenMinutes); + return rollingStartNumberInstant.isBeforeDateOf(now.plusDays(2)); + }) + .collect(Collectors.toList()); + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/IOSLegacyProblemRPLT144Modifier.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/IOSLegacyProblemRPLT144Modifier.java new file mode 100644 index 00000000..c62708fe --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/IOSLegacyProblemRPLT144Modifier.java @@ -0,0 +1,36 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier; + +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** + * Overwrite the rolling period with the default value of 144 so that iOS does not reject the keys. + * Since version 1.5 of the GAEN on Android, TEKs with a rolling period < 144 can be released. + * Unfortunately these keys are rejected by iOS, so this filter sets the default value. There are + * two downsides to this: + * + *
    + *
  • some more work for the GAEN to verify the keys + *
  • a same-day key with original rolling period < 144 will be released later and thus delay + * detection of an eventual exposition + *
+ */ +public class IOSLegacyProblemRPLT144Modifier implements KeyInsertionModifier { + + @Override + public List modify( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + for (GaenKey key : content) { + key.setRollingPeriod(144); + } + return content; + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/KeyInsertionModifier.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/KeyInsertionModifier.java new file mode 100644 index 00000000..e83c1eb7 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/KeyInsertionModifier.java @@ -0,0 +1,36 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier; + +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.InsertException; +import org.dpppt.backend.sdk.ws.insertmanager.InsertManager; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; + +/** Interface for key modifiers than can be configured in the {@link InsertManager} */ +public interface KeyInsertionModifier { + + /** + * The {@link InsertManager} goes through all configured key modifiers and calls them with a list + * of {@link GaenKey} where the modifieres are applied before inserting into the database. + * + * @param now current timestamp + * @param content the list of new gaen keys for modification + * @param osType the os type of the client which uploaded the keys + * @param osVersion the os version of the client which uploaded the keys + * @param appVersion the app version of the client which uploaded the keys + * @param principal the authorization context which belongs to the uploaded keys. Depending on the + * configured system, this could be a JWT token for example. + * @return + * @throws InsertException + */ + public List modify( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException; +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/OldAndroid0RPModifier.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/OldAndroid0RPModifier.java new file mode 100644 index 00000000..8c4038f9 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionmodifier/OldAndroid0RPModifier.java @@ -0,0 +1,39 @@ +package org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier; + +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.OSType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Some early builds of Google's Exposure Notification API returned TEKs with rolling period set to + * '0'. According to the specification, this is invalid and will cause both Android and iOS to + * drop/ignore the key. To mitigate ignoring TEKs from these builds altogether, the rolling period + * is increased to '144' (one full day). This should not happen anymore and can be removed in the + * near future. Until then we are going to log whenever this happens to be able to monitor this + * problem. + */ +public class OldAndroid0RPModifier implements KeyInsertionModifier { + + private static final Logger logger = LoggerFactory.getLogger(OldAndroid0RPModifier.class); + + @Override + public List modify( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) { + for (GaenKey gaenKey : content) { + if (gaenKey.getRollingPeriod().equals(0)) { + logger.error("We got a rollingPeriod of 0 ({},{},{})", osType, osVersion, appVersion); + gaenKey.setRollingPeriod(144); + } + } + return content; + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/security/gaen/JWTValidateRequest.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/security/gaen/JWTValidateRequest.java index 68d68217..17aadccd 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/security/gaen/JWTValidateRequest.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/security/gaen/JWTValidateRequest.java @@ -18,11 +18,8 @@ import org.springframework.security.oauth2.jwt.Jwt; public class JWTValidateRequest implements ValidateRequest { - private final ValidationUtils validationUtils; - public JWTValidateRequest(ValidationUtils validationUtils) { - this.validationUtils = validationUtils; - } + public JWTValidateRequest(ValidationUtils validationUtils) {} @Override public boolean isValid(Object authObject) throws WrongScopeException { @@ -39,7 +36,7 @@ public boolean isValid(Object authObject) throws WrongScopeException { @Override public long validateKeyDate(UTCInstant now, Object authObject, Object others) - throws ClaimIsBeforeOnsetException, InvalidDateException { + throws ClaimIsBeforeOnsetException { if (authObject instanceof Jwt) { Jwt token = (Jwt) authObject; var jwtKeyDate = UTCInstant.parseDate(token.getClaim("onset")); @@ -48,8 +45,6 @@ public long validateKeyDate(UTCInstant now, Object authObject, Object others) var keyDate = UTCInstant.of(request.getRollingStartNumber(), GaenUnit.TenMinutes); if (keyDate.isBeforeEpochMillisOf(jwtKeyDate)) { throw new ClaimIsBeforeOnsetException(); - } else if (!validationUtils.isDateInRange(keyDate, now)) { - throw new InvalidDateException(); } jwtKeyDate = keyDate; } diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/util/ValidationUtils.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/util/ValidationUtils.java index 8e849d25..f8274056 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/util/ValidationUtils.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/util/ValidationUtils.java @@ -11,7 +11,7 @@ import java.time.Duration; import java.util.Base64; -import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.model.gaen.GaenUnit; import org.dpppt.backend.sdk.utils.UTCInstant; import org.springframework.security.oauth2.jwt.Jwt; @@ -36,18 +36,15 @@ public ValidationUtils(int keyLengthBytes, Duration retentionPeriod, Long releas } /** - * Check the validty of a base64 value + * Check the validity of a base64 encoded key by decoding it and checking the key length * * @param value representation of a base64 value * @return if _value_ is a valid representation */ - public boolean isValidBase64Key(String value) { + public boolean isValidKeyFormat(String value) { try { byte[] key = Base64.getDecoder().decode(value); - if (key.length != KEY_LENGTH_BYTES) { - return false; - } - return true; + return key.length == KEY_LENGTH_BYTES; } catch (Exception e) { return false; } @@ -101,7 +98,7 @@ public boolean isValidBatchReleaseTime(UTCInstant batchReleaseTime, UTCInstant n return this.isDateInRange(batchReleaseTime, now); } - public void validateDelayedKeyDate(UTCInstant now, UTCInstant delayedKeyDate) + public void assertDelayedKeyDate(UTCInstant now, UTCInstant delayedKeyDate) throws DelayedKeyDateIsInvalid { if (delayedKeyDate.isBeforeDateOf(now.getLocalDate().minusDays(1)) || delayedKeyDate.isAfterDateOf(now.getLocalDate().plusDays(1))) { @@ -109,19 +106,15 @@ public void validateDelayedKeyDate(UTCInstant now, UTCInstant delayedKeyDate) } } - public void checkForDelayedKeyDateClaim(Object principal, GaenKey delayedKey) - throws DelayedKeyDateClaimIsWrong { - if (principal instanceof Jwt - && Boolean.FALSE.equals(((Jwt) principal).containsClaim("delayedKeyDate"))) { - throw new DelayedKeyDateClaimIsWrong(); - } + public UTCInstant getDelayedKeyDateClaim(Object principal) throws DelayedKeyDateClaimIsMissing { if (principal instanceof Jwt) { var jwt = (Jwt) principal; - var claimKeyDate = Integer.parseInt(jwt.getClaimAsString("delayedKeyDate")); - if (!delayedKey.getRollingStartNumber().equals(claimKeyDate)) { - throw new DelayedKeyDateClaimIsWrong(); + if (jwt.containsClaim("delayedKeyDate")) { + return UTCInstant.of( + Integer.parseInt(jwt.getClaimAsString("delayedKeyDate")), GaenUnit.TenMinutes); } } + throw new DelayedKeyDateClaimIsMissing(); } public boolean jwtIsFake(Object principal) { @@ -141,7 +134,7 @@ public class DelayedKeyDateIsInvalid extends Exception { private static final long serialVersionUID = -2667236967819549686L; } - public class DelayedKeyDateClaimIsWrong extends Exception { + public class DelayedKeyDateClaimIsMissing extends Exception { /** */ private static final long serialVersionUID = 4683923905451080793L; diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/DPPPTControllerTest.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/DPPPTControllerTest.java index d94cacc7..7244219c 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/DPPPTControllerTest.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/DPPPTControllerTest.java @@ -306,17 +306,17 @@ public void cannotUseKeyDateBeforeOnset() throws Exception { createToken( UTCInstant.now().plusMinutes(5), UTCInstant.now().getLocalDate().format(DateTimeFormatter.ISO_DATE)); - - mockMvc - .perform( - post("/v1/exposed") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") - .content(json(exposeeRequest))) - .andExpect(status().is(400)) - .andReturn() - .getResponse(); + MockHttpServletResponse response = + mockMvc + .perform( + post("/v1/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", "MockMVC") + .content(json(exposeeRequest))) + .andExpect(status().is(400)) + .andReturn() + .getResponse(); } @Test diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTest.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTest.java index fd3f299e..e897c87c 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTest.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTest.java @@ -78,6 +78,8 @@ public class GaenControllerTest extends BaseControllerTest { @Autowired KeyVault keyVault; @Autowired GAENDataService gaenDataService; Long releaseBucketDuration = 7200000L; + private static final String androidUserAgent = + "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29"; private static final Logger logger = LoggerFactory.getLogger(GaenControllerTest.class); @@ -133,13 +135,13 @@ private void testNKeys(UTCInstant now, int n, boolean shouldSucceed) throws Exce var gaenKey1 = new GaenKey(); gaenKey1.setRollingStartNumber((int) now.atStartOfDay().minusDays(1).get10MinutesSince1970()); gaenKey1.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes01".getBytes("UTF-8"))); - gaenKey1.setRollingPeriod(0); + gaenKey1.setRollingPeriod(144); gaenKey1.setFake(0); gaenKey1.setTransmissionRiskLevel(0); var gaenKey2 = new GaenKey(); gaenKey2.setRollingStartNumber((int) now.atStartOfDay().minusDays(1).get10MinutesSince1970()); gaenKey2.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes02".getBytes("UTF-8"))); - gaenKey2.setRollingPeriod(0); + gaenKey2.setRollingPeriod(144); gaenKey2.setFake(0); gaenKey2.setTransmissionRiskLevel(0); List exposedKeys = new ArrayList<>(); @@ -164,7 +166,7 @@ private void testNKeys(UTCInstant now, int n, boolean shouldSucceed) throws Exce post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))); MvcResult response; @@ -181,7 +183,7 @@ private void testNKeys(UTCInstant now, int n, boolean shouldSucceed) throws Exce post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(request().asyncNotStarted()) @@ -247,7 +249,7 @@ public void testAllKeysWrongButStill200() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -259,7 +261,7 @@ public void testAllKeysWrongButStill200() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(request().asyncNotStarted()) @@ -317,7 +319,7 @@ public void testSecurityHeaders() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", androidUserAgent)) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); @@ -368,7 +370,7 @@ public void testUploadWithNegativeRollingPeriodFails() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -439,7 +441,7 @@ public void testMultipleKeyUploadFake() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -450,7 +452,7 @@ public void testMultipleKeyUploadFake() throws Exception { post("/v1/gaen/exposedlist") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -506,7 +508,7 @@ public void testMultipleKeyUploadFakeAllKeysNeedToBeFake() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -517,7 +519,7 @@ public void testMultipleKeyUploadFakeAllKeysNeedToBeFake() throws Exception { post("/v1/gaen/exposedlist") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -573,7 +575,7 @@ public void testMultipleKeyUploadFakeIfJWTNotFakeAllKeysCanBeFake() throws Excep post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -584,7 +586,7 @@ public void testMultipleKeyUploadFakeIfJWTNotFakeAllKeysCanBeFake() throws Excep post("/v1/gaen/exposedlist") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -607,7 +609,7 @@ public void testMultipleKeyNonEmptyUpload() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(400)) @@ -619,7 +621,7 @@ public void testMultipleKeyNonEmptyUpload() throws Exception { post("/v1/gaen/exposedlist") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -643,7 +645,7 @@ public void testMultipleKeyNonNullUpload() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(400)) @@ -655,7 +657,7 @@ public void testMultipleKeyNonNullUpload() throws Exception { post("/v1/gaen/exposedlist") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtToken) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -676,7 +678,7 @@ public void keyNeedsToBeBase64() throws Exception { key.setRollingPeriod(144); key.setRollingStartNumber((int) now.get10MinutesSince1970()); key.setTransmissionRiskLevel(1); - key.setFake(1); + key.setFake(0); List keys = new ArrayList<>(); keys.add(key); for (int i = 0; i < 13; i++) { @@ -691,18 +693,16 @@ public void keyNeedsToBeBase64() throws Exception { } exposeeRequest.setGaenKeys(keys); - String token = createToken(true, now.plusMinutes(5)); - MvcResult response = - mockMvc - .perform( - post("/v1/gaen/exposed") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") - .content(json(exposeeRequest))) - .andExpect(request().asyncStarted()) - .andReturn(); - mockMvc.perform(asyncDispatch(response)).andExpect(status().is(400)); + String token = createToken(false, now.plusMinutes(5)); + mockMvc + .perform( + post("/v1/gaen/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", androidUserAgent) + .content(json(exposeeRequest))) + .andExpect(status().is(400)) + .andReturn(); } @Test @@ -743,7 +743,7 @@ public void testKeyDateBeforeOnsetIsNotInserted() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andExpect(status().is(200)) @@ -793,7 +793,7 @@ public void cannotUseExpiredToken() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(request().asyncNotStarted()) .andExpect(status().is4xxClientError()) @@ -806,10 +806,9 @@ public void cannotUseKeyDateInFuture() throws Exception { var midnight = now.atStartOfDay(); GaenRequest exposeeRequest = new GaenRequest(); - var duration = midnight.plusDays(1).get10MinutesSince1970(); - exposeeRequest.setDelayedKeyDate((int) duration); + exposeeRequest.setDelayedKeyDate((int) midnight.get10MinutesSince1970()); GaenKey key = new GaenKey(); - key.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes--".getBytes("UTF-8"))); + key.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes++".getBytes("UTF-8"))); key.setRollingPeriod(144); key.setRollingStartNumber((int) midnight.plusDays(2).get10MinutesSince1970()); key.setTransmissionRiskLevel(1); @@ -820,7 +819,7 @@ public void cannotUseKeyDateInFuture() throws Exception { var tmpKey = new GaenKey(); tmpKey.setRollingStartNumber( (int) Duration.ofMillis(Instant.now().toEpochMilli()).dividedBy(Duration.ofMinutes(10))); - tmpKey.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes--".getBytes("UTF-8"))); + tmpKey.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes++".getBytes("UTF-8"))); tmpKey.setRollingPeriod(144); tmpKey.setFake(1); tmpKey.setTransmissionRiskLevel(0); @@ -830,17 +829,16 @@ public void cannotUseKeyDateInFuture() throws Exception { String token = createToken(now.plusMinutes(5)); - mockMvc - .perform( - post("/v1/gaen/exposed") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") - .content(json(exposeeRequest))) - .andExpect(request().asyncStarted()) - .andExpect(status().is(200)) - .andReturn(); - + MvcResult response = + mockMvc + .perform( + post("/v1/gaen/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", androidUserAgent) + .content(json(exposeeRequest))) + .andExpect(status().is(200)) + .andReturn(); var result = gaenDataService.getSortedExposedForKeyDate( midnight.plusDays(2).getTimestamp(), @@ -867,7 +865,8 @@ public void keyDateNotOlderThan21Days() throws Exception { keys.add(key); for (int i = 0; i < 13; i++) { var tmpKey = new GaenKey(); - tmpKey.setRollingStartNumber((int) now.get10MinutesSince1970()); + tmpKey.setRollingStartNumber( + (int) Duration.ofMillis(Instant.now().toEpochMilli()).dividedBy(Duration.ofMinutes(10))); tmpKey.setKeyData(Base64.getEncoder().encodeToString("testKey32Bytes--".getBytes("UTF-8"))); tmpKey.setRollingPeriod(144); tmpKey.setFake(1); @@ -878,17 +877,17 @@ public void keyDateNotOlderThan21Days() throws Exception { String token = createToken(now.plusMinutes(5), "2020-01-01"); - mockMvc - .perform( - post("/v1/gaen/exposed") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") - .content(json(exposeeRequest))) - .andExpect(request().asyncStarted()) - .andExpect(status().is(200)) - .andReturn(); - + MvcResult response = + mockMvc + .perform( + post("/v1/gaen/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", androidUserAgent) + .content(json(exposeeRequest))) + .andExpect(request().asyncStarted()) + .andExpect(status().is(200)) + .andReturn(); var result = gaenDataService.getSortedExposedForKeyDate( midnight.minusDays(22).getTimestamp(), @@ -932,14 +931,11 @@ public void cannotUseTokenWithWrongScope() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) - .andExpect(request().asyncStarted()) + .andExpect(status().is(403)) .andReturn(); - mockMvc - .perform(asyncDispatch(response)) - .andExpect(status().is(403)) - .andExpect(content().string("")); + // Also for a 403 response, the token cannot be used a 2nd time response = mockMvc @@ -947,7 +943,7 @@ public void cannotUseTokenWithWrongScope() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(401)) @@ -984,7 +980,7 @@ public void uploadKeysAndUploadKeyNextDay() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1016,14 +1012,15 @@ public void uploadKeysAndUploadKeyNextDay() throws Exception { post("/v1/gaen/exposednextday") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtString) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(secondDay))) .andExpect(request().asyncStarted()) .andReturn(); mockMvc.perform(asyncDispatch(responseAsync)).andExpect(status().is(200)); } - @Test + // @Test + // TODO: Is this still a requirement? Currently the key just gets filtered out public void uploadKeysAndUploadKeyNextDayWithNegativeRollingPeriodFails() throws Exception { var now = UTCInstant.now(); var midnight = now.atStartOfDay(); @@ -1052,7 +1049,7 @@ public void uploadKeysAndUploadKeyNextDayWithNegativeRollingPeriodFails() throws post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1084,7 +1081,7 @@ public void uploadKeysAndUploadKeyNextDayWithNegativeRollingPeriodFails() throws post("/v1/gaen/exposednextday") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtString) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(secondDay))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1124,28 +1121,33 @@ public void delayedKeyDateBoundaryCheck() throws Exception { exposeeRequest.setDelayedKeyDate(delayedKeyDateSent); exposeeRequest.setGaenKeys(keys); String token = createToken(now.plusMinutes(5)); - MvcResult responseAsync = - mockMvc - .perform( - post("/v1/gaen/exposed") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") - .content(json(exposeeRequest))) - .andExpect(request().asyncStarted()) - .andReturn(); if (pass) { + MvcResult responseAsync = + mockMvc + .perform( + post("/v1/gaen/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", androidUserAgent) + .content(json(exposeeRequest))) + .andExpect(request().asyncStarted()) + .andReturn(); mockMvc .perform(asyncDispatch(responseAsync)) .andExpect(status().is(200)) .andReturn() .getResponse(); } else { - mockMvc - .perform(asyncDispatch(responseAsync)) - .andExpect(status().is(400)) - .andReturn() - .getResponse(); + MvcResult responseAsync = + mockMvc + .perform( + post("/v1/gaen/exposed") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .header("User-Agent", androidUserAgent) + .content(json(exposeeRequest))) + .andExpect(status().is(400)) + .andReturn(); } } } @@ -1180,7 +1182,7 @@ public void testTokenValiditySurpassesMaxJwtValidity() throws Exception { post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", androidUserAgent) .content(json(exposeeRequest))) .andExpect(status().is(401)); } @@ -1203,7 +1205,8 @@ public void testDebugController() throws Exception { MockHttpServletResponse response = mockMvc .perform( - get("/v1/debug/exposed/" + midnight.getTimestamp()).header("User-Agent", "MockMVC")) + get("/v1/debug/exposed/" + midnight.getTimestamp()) + .header("User-Agent", androidUserAgent)) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); @@ -1271,7 +1274,7 @@ public void testNonEmptyResponseAnd304() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", androidUserAgent)) .andExpect(status().isOk()) .andReturn() .getResponse(); @@ -1305,7 +1308,7 @@ public void testEtag() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", androidUserAgent)) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); @@ -1318,7 +1321,7 @@ public void testEtag() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", androidUserAgent)) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); @@ -1333,7 +1336,7 @@ public void testEtag() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", androidUserAgent)) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManagerTest.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManagerTest.java new file mode 100644 index 00000000..382312f9 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/insertmanager/InsertManagerTest.java @@ -0,0 +1,133 @@ +package org.dpppt.backend.sdk.ws.insertmanager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.semver.Version; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.insertmanager.insertionmodifier.OldAndroid0RPModifier; +import org.dpppt.backend.sdk.ws.util.ValidationUtils; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +public class InsertManagerTest { + @Test + public void testOSEnumWorks() { + assertEquals("Android", OSType.ANDROID.toString()); + assertEquals("iOS", OSType.IOS.toString()); + } + + @Test + public void emptyListShouldNotFail() { + Object theException = null; + try { + InsertManager manager = + new InsertManager( + new MockDataSource(), + new ValidationUtils(16, Duration.ofDays(14), Duration.ofHours(2).toMillis())); + manager.insertIntoDatabase(new ArrayList<>(), null, null, null); + } catch (Exception ex) { + theException = ex; + } + assertNull(theException); + } + + @Test + public void nullListShouldNotFail() throws Exception { + Object theException = null; + try { + InsertManager manager = + new InsertManager( + new MockDataSource(), + new ValidationUtils(16, Duration.ofDays(14), Duration.ofHours(2).toMillis())); + manager.insertIntoDatabase(null, null, null, null); + } catch (Exception ex) { + theException = ex; + } + assertNull(theException); + } + + @Test + public void wrongHeaderShouldNotFail() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(InsertManager.class); + var appender = new TestAppender(); + appender.setContext(logger.getLoggerContext()); + appender.start(); + logger.addAppender(appender); + InsertManager manager = + new InsertManager( + new MockDataSource(), + new ValidationUtils(16, Duration.ofDays(14), Duration.ofHours(2).toMillis())); + var key = + new GaenKey("POSTMAN+POSTMAN+", (int) UTCInstant.now().get10MinutesSince1970(), 144, 0); + try { + manager.insertIntoDatabase(List.of(key), "test", null, UTCInstant.now()); + } catch (RuntimeException ex) { + if (!ex.getMessage().equals("UPSERT_EXPOSEES")) { + throw ex; + } + } + appender.stop(); + for (var event : appender.getLog()) { + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("We received an invalid header, setting default.", event.getMessage()); + } + } + + @Test + public void iosRP0ShouldLog() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(OldAndroid0RPModifier.class); + var appender = new TestAppender(); + appender.setContext(logger.getLoggerContext()); + appender.start(); + logger.addAppender(appender); + InsertManager manager = + new InsertManager( + new MockDataSource(), + new ValidationUtils(16, Duration.ofDays(14), Duration.ofHours(2).toMillis())); + manager.addModifier(new OldAndroid0RPModifier()); + var key = new GaenKey("POSTMAN+POSTMAN+", (int) UTCInstant.now().get10MinutesSince1970(), 0, 0); + try { + manager.insertIntoDatabase( + List.of(key), "org.dpppt.testrunner;1.0.0;1;iOS;29", null, UTCInstant.now()); + } catch (RuntimeException ex) { + if (!ex.getMessage().equals("UPSERT_EXPOSEES")) { + throw ex; + } + } + appender.stop(); + assertEquals(1, appender.getLog().size()); + for (var event : appender.getLog()) { + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("We got a rollingPeriod of 0 ({},{},{})", event.getMessage()); + // osType, osVersion, appVersion + var osType = (OSType) event.getArgumentArray()[0]; + var osVersion = (Version) event.getArgumentArray()[1]; + var appVersion = (Version) event.getArgumentArray()[2]; + assertEquals(OSType.IOS, osType); + assertEquals("29.0.0", osVersion.toString()); + assertEquals("1.0.0+1", appVersion.toString()); + } + } + + class TestAppender extends AppenderBase { + private final List log = new ArrayList(); + + @Override + protected void append(ILoggingEvent eventObject) { + log.add(eventObject); + } + + public List getLog() { + return log; + } + } +} diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/util/SemverTests.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/util/SemverTests.java new file mode 100644 index 00000000..89d5c25e --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/util/SemverTests.java @@ -0,0 +1,134 @@ +package org.dpppt.backend.sdk.ws.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import org.dpppt.backend.sdk.semver.Version; +import org.junit.Test; + +public class SemverTests { + + @Test + public void testToString() throws Exception { + var v = new Version("ios-1.1.3-test+meta"); + assertEquals("1.1.3-test+meta", v.toString()); + v = new Version("1.1.3+meta"); + assertEquals("1.1.3+meta", v.toString()); + v = new Version("ios-1.1.3-meta"); + assertEquals("1.1.3-meta", v.toString()); + v = new Version("ios-1.1.3"); + assertEquals("1.1.3", v.toString()); + v = new Version("1.1.3"); + assertEquals("1.1.3", v.toString()); + } + + @Test + public void testVersionFromString() throws Exception { + var cases = + List.of( + new Version("ios-0.1.0"), + new Version("android-0.1.1"), + new Version("0.2.0"), + new Version("1.0.0-prerelease"), + new Version("1.0.0"), + new Version("1.0.1+ios")); + for (int i = 0; i < cases.size(); i++) { + var currentVersion = cases.get(i); + assertTrue(currentVersion.isSameVersionAs(currentVersion)); + for (int j = 0; j < i; j++) { + var olderVersion = cases.get(j); + assertTrue(currentVersion.isLargerVersionThan(olderVersion)); + } + } + var releaseVersion = new Version("1.0.0"); + var metaInfoVersion = new Version("1.0.0+ios"); + assertTrue(releaseVersion.isSameVersionAs(metaInfoVersion)); + assertNotEquals(metaInfoVersion, releaseVersion); + var sameIosVersion = new Version("1.0.0+ios"); + assertEquals(sameIosVersion, metaInfoVersion); + } + + @Test + public void testPlatform() throws Exception { + var iosNonStandard = new Version("ios-1.0.0"); + var iosStandard = new Version("1.0.0+ios"); + assertTrue(iosNonStandard.isIOS()); + assertTrue(iosStandard.isIOS()); + assertFalse(iosNonStandard.isAndroid()); + assertFalse(iosStandard.isAndroid()); + + var androidNonStandard = new Version("android-1.0.0"); + var androidStandard = new Version("1.0.0+android"); + assertFalse(androidNonStandard.isIOS()); + assertFalse(androidStandard.isIOS()); + assertTrue(androidNonStandard.isAndroid()); + assertTrue(androidStandard.isAndroid()); + + var random = new Version("1.0.0"); + assertFalse(random.isAndroid()); + assertFalse(random.isIOS()); + } + + @Test + public void testVersionFromExplicit() throws Exception { + var cases = + List.of( + new Version(0, 1, 0), + new Version(0, 1, 1), + new Version(0, 2, 0), + new Version(1, 0, 0, "prerelease", ""), + new Version(1, 0, 0), + new Version(1, 0, 1, "", "ios")); + for (int i = 0; i < cases.size(); i++) { + var currentVersion = cases.get(i); + assertTrue(currentVersion.isSameVersionAs(currentVersion)); + for (int j = 0; j < i; j++) { + var olderVersion = cases.get(j); + assertTrue(currentVersion.isLargerVersionThan(olderVersion)); + } + } + var releaseVersion = new Version(1, 0, 0); + var metaInfoVersion = new Version(1, 0, 0, "", "ios"); + assertTrue(releaseVersion.isSameVersionAs(metaInfoVersion)); + assertNotEquals(metaInfoVersion, releaseVersion); + var sameIosVersion = new Version(1, 0, 0, "", "ios"); + assertEquals(sameIosVersion, metaInfoVersion); + } + + @Test + public void testMissingMinorOrPatch() throws Exception { + var apiLevel = "29"; + var iosVersion = "13.6"; + var apiLevelWithMeta = "29+test"; + var iosVersionWithMeta = "13.6+test"; + var apiLevelVersion = new Version(apiLevel); + assertTrue( + apiLevelVersion.getMajor().equals(29) + && apiLevelVersion.getMinor().equals(0) + && apiLevelVersion.getPatch().equals(0)); + + var iosVersionVersion = new Version(iosVersion); + assertTrue( + iosVersionVersion.getMajor().equals(13) + && iosVersionVersion.getMinor().equals(6) + && iosVersionVersion.getPatch().equals(0)); + + var apiLevelWithMetaVersion = new Version(apiLevelWithMeta); + assertTrue( + apiLevelWithMetaVersion.getMajor().equals(29) + && apiLevelWithMetaVersion.getMinor().equals(0) + && apiLevelWithMetaVersion.getPatch().equals(0) + && apiLevelWithMetaVersion.getMetaInfo().equals("test")); + + var iosVersionVersionMeta = new Version(iosVersionWithMeta); + + assertTrue( + iosVersionVersionMeta.getMajor().equals(13) + && iosVersionVersionMeta.getMinor().equals(6) + && iosVersionVersionMeta.getPatch().equals(0) + && iosVersionVersionMeta.getMetaInfo().equals("test")); + } +}