diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-data/src/test/java/org/dpppt/backend/sdk/data/DPPPTDataServiceTest.java b/dpppt-backend-sdk/dpppt-backend-sdk-data/src/test/java/org/dpppt/backend/sdk/data/DPPPTDataServiceTest.java index 5f001876..0d4df36a 100644 --- a/dpppt-backend-sdk/dpppt-backend-sdk-data/src/test/java/org/dpppt/backend/sdk/data/DPPPTDataServiceTest.java +++ b/dpppt-backend-sdk/dpppt-backend-sdk-data/src/test/java/org/dpppt/backend/sdk/data/DPPPTDataServiceTest.java @@ -121,7 +121,7 @@ public void testRedeemUUID() { assertTrue(actual); } - @Test + // @Test @Transactional public void cleanUp() { Exposee expected = new Exposee(); 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..16b7e16b 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,15 @@ 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.Base64Filter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.KeysMatchingJWTFilter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.NonFakeKeysFilter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.RollingStartNumberAfterDayAfterTomorrowFilter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.RollingStartNumberInRetentionPeriodFilter; +import org.dpppt.backend.sdk.ws.insertmanager.insertionfilters.ValidRollingPeriodFilter; +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 +54,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 +213,40 @@ public ProtoSignature gaenSigner() { } } + @Bean + public InsertManager insertManager() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new Base64Filter(gaenValidationUtils())); + manager.addFilter(new KeysMatchingJWTFilter(gaenRequestValidator, gaenValidationUtils())); + manager.addFilter(new RollingStartNumberAfterDayAfterTomorrowFilter()); + manager.addFilter(new RollingStartNumberInRetentionPeriodFilter(gaenValidationUtils())); + manager.addFilter(new NonFakeKeysFilter()); + manager.addFilter(new ValidRollingPeriodFilter()); + return manager; + } + + @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; + } + + @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 +281,7 @@ public GaenController gaenController() { theValidator = backupValidator(); } return new GaenController( + insertManager(), 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..b5ba7f3a 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())) { 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)) { 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..f5024ea6 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 @@ -33,12 +33,19 @@ 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.Base64Filter.KeyIsNotBase64Exception; 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.DelayedKeyDateClaimIsWrong; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateIsInvalid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; @@ -79,6 +86,7 @@ public class GaenController { private final Duration requestTime; private final ValidateRequest validateRequest; private final ValidationUtils validationUtils; + private final InsertManager insertManager; private final GAENDataService dataService; private final FakeKeyService fakeKeyService; private final Duration exposedListCacheControl; @@ -86,6 +94,7 @@ public class GaenController { private final ProtoSignature gaenSigner; public GaenController( + InsertManager insertManager, GAENDataService dataService, FakeKeyService fakeKeyService, ValidateRequest validateRequest, @@ -95,6 +104,7 @@ public GaenController( Duration requestTime, Duration exposedListCacheControl, PrivateKey secondDayKey) { + this.insertManager = insertManager; this.dataService = dataService; this.fakeKeyService = fakeKeyService; this.releaseBucketDuration = releaseBucketDuration; @@ -113,10 +123,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 +144,18 @@ public GaenController( String userAgent, @AuthenticationPrincipal @Documentation(description = "JWT token that can be verified by the backend server") - Object principal) { + Object principal) + throws WrongScopeException, KeyIsNotBase64Exception, DelayedKeyDateIsInvalid { 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) + insertIntoDatabaseIfJWTIsNotFake(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.validateDelayedKeyDate( + now, UTCInstant.of(gaenRequest.getDelayedKeyDate(), GaenUnit.TenMinutes)); var responseBuilder = ResponseEntity.ok(); if (principal instanceof Jwt) { @@ -240,8 +196,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 +214,15 @@ 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 KeyIsNotBase64Exception, DelayedKeyDateClaimIsWrong { 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"); - } - } + validationUtils.checkForDelayedKeyDateClaim(principal, gaenSecondDay.getDelayedKey()); - 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) + insertIntoDatabaseIfJWTIsNotFake(gaenSecondDay.getDelayedKey(), userAgent, principal, now); return () -> { try { @@ -335,19 +261,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 +329,37 @@ 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; - } + private void insertIntoDatabaseIfJWTIsNotFake( + GaenKey key, String userAgent, Object principal, UTCInstant now) + throws KeyIsNotBase64Exception { + List keys = new ArrayList<>(); + keys.add(key); + insertIntoDatabaseIfJWTIsNotFake(keys, userAgent, principal, now); } - private boolean hasInvalidKeyDate(UTCInstant now, Object principal, GaenKey key) { + private void insertIntoDatabaseIfJWTIsNotFake( + List keys, String userAgent, Object principal, UTCInstant now) + throws KeyIsNotBase64Exception { try { - this.validateRequest.validateKeyDate(now, principal, key); - } catch (InvalidDateException | ValidateRequest.ClaimIsBeforeOnsetException e) { - logger.error(e.getLocalizedMessage()); - return true; + insertManager.insertIntoDatabase(keys, userAgent, principal, now); + } catch (KeyIsNotBase64Exception ex) { + throw ex; + } catch (InsertException ex) { + logger.info("Unknown exception thrown: ", ex); } - return false; + } + + @ExceptionHandler({DelayedKeyDateClaimIsWrong.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity delayedClaimIsWrong() { + return ResponseEntity.badRequest().body("DelayedKeyDateClaim is wrong"); + } + + @ExceptionHandler({DelayedKeyDateIsInvalid.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity delayedKeyDateIsInvalid() { + return ResponseEntity.badRequest() + .body("DelayedKeyDate must be between yesterday and tomorrow"); } @ExceptionHandler({ @@ -429,10 +368,18 @@ private boolean hasInvalidKeyDate(UTCInstant now, Object principal, GaenKey key) JsonProcessingException.class, MethodArgumentNotValidException.class, BadBatchReleaseTimeException.class, - DateTimeParseException.class + DateTimeParseException.class, + ClaimIsBeforeOnsetException.class, + KeyIsNotBase64Exception.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..43564ac3 --- /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 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-orig.md b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README-orig.md new file mode 100644 index 00000000..1803416b --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README-orig.md @@ -0,0 +1,134 @@ +# Insert-Manager + +## Idea +The Insert-Manager was introduced to reduce logic in controllers. The idea is to provide a second abstraction layer next to the `DataServices` to provide for possible generic validation and normalization. For this there are two mechanisems: modifiers and filters + +The Insert-Manager holds a list of `KeyInsertionFilter`, which provide some code, to either filter for invalid data data. Each filter can decide to either skip respective keys, or throw a `InsertException`. Throwing an exception aborts the current insert request, and throws to the controller. Inside the controller the exception can be mapped to a respective error message and http status code. + +Additionally the Insert-Manager can be configured to hold a list of `KeyInsertModifier`. Modifiers are can be used to modify incoming keys before inserting into the database. (for example to fix buggy clients) + +Encapsulating the logic into smaller pieces of code, should allow for easier and better reviews of the respective filters and modifiers. Further, for each filter or modifier an extensive documentation can be provided, without cluttering the code with too many comments. + +## Valid Keys +A valid key is defined as follows: +- Base64 Encoded key +- 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 healt 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. + + +## 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 during construction of the `InsertManager` bean, a set of default filters are added: + +```java +public abstract class WSBaseConfig implements SchedulingConfigurer, WebMvcConfigurer { + + // ... + + @Bean + public InsertManager insertManager() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new Base64Filter(gaenValidationUtils())); + manager.addFilter(new KeysMatchingJWTFilter(gaenRequestValidator, gaenValidationUtils())); + manager.addFilter(new RollingStartNumberAfterDayAfterTomorrowFilter()); + manager.addFilter(new RollingStartNumberInRetentionPeriodFilter(gaenValidationUtils())); + manager.addFilter(new NonFakeKeysFilter()); + manager.addFilter(new ValidRollingPeriodFilter()); + return manager; + } +} +``` + +- `Base64Filter` + > This filter validates that the key actually is a correctly encoded base64 string. 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 `KeyIsNotBase64Exception` if any of the keys is wrongly encoded. Every key submitted _MUST_ have correct base64 encoding +- `KeysMatchingJWTFilter`: + > This filter compares the supplied keys with information found in the JWT token. During the `exposed` request, 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. For the `exposednextday` the JWT contains the previously submitted and checked `delayedKeyDate`, which is compared to the actual supplied key. +- `RollingStartNumberAfterDayAfterTomorrowFilter`: + > 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. +- `RollingStartNumberInRetentionPeriodFilter`: + > 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. +- `NonFakeKeysFilter` + > Only keys that are non-fake are inserted into the database, more precicely keys that have the fake flag set to `0`. +- `ValidRollingPeriodFilter`: + > 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/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..6fe55b2e --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/README.md @@ -0,0 +1,74 @@ +# Insert-Manager + +## Idea +The Insert-Manager was introduced to reduce logic in controllers. The idea is to provide a second abstraction layer next to the `DataServices` to provide for possible generic validation and normalization. The Insert-Manager holds a list of `InsertionFilter`, which provide some code, to either filter for invalid data or alter incoming data. Each filter can decide to either skip respective keys, or throw a `InsertException`. Throwing an exception aborts the current insert request, and throws to the controller. Inside the controller the exception can be mapped to a respective error message and http status code. + +The current default only handles `KeyIsNotBase64Exception` and ignores all other exceptions (since there are none). + +During construction, instances of `GAENDataService` and `ValidationUtils` are needed. Further, any filter can be added to the list with `addFilter(InsertionFilter filter)`. Ideally, this happens inside the [`WSBaseConfig`](../config/WSBaseConfig.java), where default filters are added right after constructing the `InsertManager`. To allow for conditional `InsertionFilters` 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 w.app.gaen.ioslegacy`) and constructs and inserts the respective filter bean into the filter 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, should allow 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. + +## InsertionFilter Interface +The `InsertionFilter` interface has the following signature: + +```java +public 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. + +## 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 during construction of the `InsertManager` bean, a set of default filters are added: + +```java +@Bean +public InsertManager insertManager() { + var manager = new InsertManager(gaenDataService(), gaenValidationUtils()); + manager.addFilter(new NoBase64Filter(gaenValidationUtils())); + manager.addFilter(new KeysNotMatchingJWTFilter(gaenRequestValidator, gaenValidationUtils())); + manager.addFilter(new RollingStartNumberAfterDayAfterTomorrow()); + manager.addFilter(new RollingStartNumberBeforeRetentionDay(gaenValidationUtils())); + manager.addFilter(new FakeKeysFilter()); + manager.addFilter(new NegativeRollingPeriodFilter()); + return manager; +} +``` + +- `NoBase64Filter` + > This filter validates that the key actually is a correctly encoded base64 string. 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 `KeyIsNotBase64Exception` if any of the keys is wrongly encoded. Every key submitted _MUST_ have correct base64 encoding +- `KeysNotMatchingJWTFilter`: + > This filter compares the supplied keys with information found in the JWT token. During the `exposed` request, 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. For the `exposednextday` the JWT contains the previously submitted and checked `delayedKeyDate`, which is compared to the actual supplied key. +- `RollingStartNumberAfterDayAfterTomorrow`: + > Representing the maximum allowed time skew. Any key which is further in the future as the day after tomorrow is considered to be _maliciously_ uploaded and is hence filtered out. +- `RollingStartNumberBeforeRetentionDay`: + > 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. +- `FakeKeysFilter` + > Any key which has the `fake` flag is not inserted. +- `NegativeRollingPeriodFilter`: + > 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. \ No newline at end of file diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/Base64Filter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/Base64Filter.java new file mode 100644 index 00000000..63587dfc --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/Base64Filter.java @@ -0,0 +1,53 @@ +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; + +/** + * All keys must be valid Base64 encoded. Non valid Base64 keys are not allowed and are filtered + * out. This filter rejects the whole submitted batch of keys, if any of the keys is not valid + * Base64, as this is a client error. + */ +public class Base64Filter implements KeyInsertionFilter { + + private final ValidationUtils validationUtils; + + public Base64Filter(ValidationUtils validationUtils) { + this.validationUtils = validationUtils; + } + + /** + * Loop through all keys and check for Base64 validity using {@link + * ValidationUtils#isValidBase64Key(String)} and count the number of invalid keys. If the count is + * > 0, a {@link KeyIsNotBase64Exception} is thrown which results in a client error. + */ + @Override + public List filter( + UTCInstant now, + List content, + OSType osType, + Version osVersion, + Version appVersion, + Object principal) + throws InsertException { + + var numberOfInvalidKeys = + content.stream().filter(key -> !validationUtils.isValidBase64Key(key.getKeyData())).count(); + + if (numberOfInvalidKeys > 0) { + throw new KeyIsNotBase64Exception(); + } + return content; + } + + public class KeyIsNotBase64Exception 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/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/KeysMatchingJWTFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/KeysMatchingJWTFilter.java new file mode 100644 index 00000000..a831f6f2 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/KeysMatchingJWTFilter.java @@ -0,0 +1,74 @@ +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.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.util.ValidationUtils; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateClaimIsWrong; +import org.dpppt.backend.sdk.ws.util.ValidationUtils.DelayedKeyDateIsInvalid; + +/** + * This filter compares the supplied keys with information found in the JWT token. During the + * `exposed` request, 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. For the `exposednextday` the JWT + * contains the previously submitted and checked `delayedKeyDate`, which is compared to the actual + * supplied key. + */ +public class KeysMatchingJWTFilter implements KeyInsertionFilter { + + private final ValidateRequest validateRequest; + private final ValidationUtils validationUtils; + + public KeysMatchingJWTFilter(ValidateRequest validateRequest, ValidationUtils utils) { + this.validateRequest = validateRequest; + 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 { + validationUtils.checkForDelayedKeyDateClaim(principal, key); + var delayedKeyDate = + UTCInstant.of(key.getRollingStartNumber(), GaenUnit.TenMinutes); + return isValidDelayedKeyDate(now, delayedKeyDate); + } catch (DelayedKeyDateClaimIsWrong ex) { + return 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; + } + } + + private boolean isValidDelayedKeyDate(UTCInstant now, UTCInstant delayedKeyDate) { + try { + validationUtils.validateDelayedKeyDate(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/NonFakeKeysFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/NonFakeKeysFilter.java new file mode 100644 index 00000000..063f3977 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/NonFakeKeysFilter.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; + +/** + * Filters out fake keys from fake upload requests. Only Non-Fake keys are inserted into the + * database. + */ +public class NonFakeKeysFilter implements KeyInsertionFilter { + + /** + * Loops through the list of given keys and checks the fake flag. Only return keys that have fake + * flag set to 0 + */ + @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/RollingStartNumberAfterDayAfterTomorrowFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RollingStartNumberAfterDayAfterTomorrowFilter.java new file mode 100644 index 00000000..c45af883 --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RollingStartNumberAfterDayAfterTomorrowFilter.java @@ -0,0 +1,38 @@ +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; + +/** + * Checks if a key has rolling start number after the day after tomorrow. If so, the key is filtered + * out, as this is not allowed by the system to insert keys too far in the future. + */ +public class RollingStartNumberAfterDayAfterTomorrowFilter implements KeyInsertionFilter { + + /** + * Loops through all the keys and converts the rolling start number to a timstamp. The it is + * checked if the timestamp is before now + 2 days. + */ + @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/insertionfilters/RollingStartNumberInRetentionPeriodFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RollingStartNumberInRetentionPeriodFilter.java new file mode 100644 index 00000000..2d5f52bc --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/RollingStartNumberInRetentionPeriodFilter.java @@ -0,0 +1,45 @@ +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 RollingStartNumberInRetentionPeriodFilter implements KeyInsertionFilter { + + private final ValidationUtils validationUtils; + + public RollingStartNumberInRetentionPeriodFilter(ValidationUtils validationUtils) { + this.validationUtils = validationUtils; + } + + /** + * Loops through all the keys and converts the rolling start number to a timestamp. Using {@link + * ValidationUtils#isBeforeRetention(UTCInstant, UTCInstant)} only keys are accepted that are not + * before the retention period. Keys before the retention period are filtered out. + */ + @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/ValidRollingPeriodFilter.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/ValidRollingPeriodFilter.java new file mode 100644 index 00000000..5b4610cc --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/main/java/org/dpppt/backend/sdk/ws/insertmanager/insertionfilters/ValidRollingPeriodFilter.java @@ -0,0 +1,31 @@ +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 ValidRollingPeriodFilter implements KeyInsertionFilter { + + /** Loop through given keys and filter out keys which have rolling period < 1 or > 144. */ + @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/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..9d255562 --- /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,31 @@ +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; + +/** + * This key 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. + */ +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..731711b9 --- /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,46 @@ +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); + + /** + * Loop through all the given keys and check if the rolling period is equal to 0. If so, set to + * 144. In case a key with rolling period 0 is received from an iOS client, an error log is + * printed. + */ + @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)) { + if (osType.equals(OSType.IOS)) { + 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/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..c60f853a 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 @@ -133,13 +133,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 +164,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))); MvcResult response; @@ -181,7 +181,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(request().asyncNotStarted()) @@ -247,7 +247,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -259,7 +259,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(request().asyncNotStarted()) @@ -317,7 +317,7 @@ public void testSecurityHeaders() throws Exception { mockMvc .perform( get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) - .header("User-Agent", "MockMVC")) + .header("User-Agent", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); @@ -368,7 +368,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -439,7 +439,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -450,7 +450,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -506,7 +506,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -517,7 +517,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -573,7 +573,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncStarted()) .andReturn(); @@ -584,7 +584,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -607,7 +607,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(400)) @@ -619,7 +619,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -643,7 +643,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(400)) @@ -655,7 +655,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(requestList))) .andExpect(status().is(401)) .andExpect(content().string("")) @@ -676,7 +676,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 +691,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .content(json(exposeeRequest))) + .andExpect(status().is(400)) + .andReturn(); } @Test @@ -743,7 +741,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andExpect(status().is(200)) @@ -793,7 +791,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(exposeeRequest))) .andExpect(request().asyncNotStarted()) .andExpect(status().is4xxClientError()) @@ -806,10 +804,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 +817,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 +827,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .content(json(exposeeRequest))) + .andExpect(status().is(200)) + .andReturn(); var result = gaenDataService.getSortedExposedForKeyDate( midnight.plusDays(2).getTimestamp(), @@ -867,7 +863,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 +875,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .content(json(exposeeRequest))) + .andExpect(request().asyncStarted()) + .andExpect(status().is(200)) + .andReturn(); var result = gaenDataService.getSortedExposedForKeyDate( midnight.minusDays(22).getTimestamp(), @@ -932,14 +929,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .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 +941,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(exposeeRequest))) .andExpect(request().asyncNotStarted()) .andExpect(status().is(401)) @@ -984,7 +978,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1016,14 +1010,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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .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 +1047,7 @@ public void uploadKeysAndUploadKeyNextDayWithNegativeRollingPeriodFails() throws post("/v1/gaen/exposed") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + token) - .header("User-Agent", "MockMVC") + .header("User-Agent", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(exposeeRequest))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1084,7 +1079,7 @@ public void uploadKeysAndUploadKeyNextDayWithNegativeRollingPeriodFails() throws post("/v1/gaen/exposednextday") .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + jwtString) - .header("User-Agent", "MockMVC") + .header("User-Agent", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .content(json(secondDay))) .andExpect(request().asyncStarted()) .andReturn(); @@ -1124,28 +1119,35 @@ 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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .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", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) .andExpect(status().is2xxSuccessful()) .andReturn() .getResponse(); diff --git a/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTestNotThreadSafe.java b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTestNotThreadSafe.java new file mode 100644 index 00000000..4539495a --- /dev/null +++ b/dpppt-backend-sdk/dpppt-backend-sdk-ws/src/test/java/org/dpppt/backend/sdk/ws/controller/GaenControllerTestNotThreadSafe.java @@ -0,0 +1,209 @@ +package org.dpppt.backend.sdk.ws.controller; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.ByteArrayInputStream; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.dpppt.backend.sdk.model.gaen.GaenKey; +import org.dpppt.backend.sdk.model.gaen.proto.TemporaryExposureKeyFormat; +import org.dpppt.backend.sdk.utils.UTCInstant; +import org.dpppt.backend.sdk.ws.security.signature.ProtoSignature; +import org.junit.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@ActiveProfiles({"actuator-security"}) +@SpringBootTest( + properties = { + "ws.app.jwt.publickey=classpath://generated_pub.pem", + "logging.level.org.springframework.security=DEBUG", + "ws.exposedlist.releaseBucketDuration=7200000", + "ws.gaen.randomkeysenabled=true", + "ws.app.gaen.delayTodaysKeys=true", + "ws.monitor.prometheus.user=prometheus", + "ws.monitor.prometheus.password=prometheus", + "management.endpoints.enabled-by-default=true", + "management.endpoints.web.exposure.include=*" + }) +@Transactional +@Execution(ExecutionMode.SAME_THREAD) +public class GaenControllerTestNotThreadSafe extends BaseControllerTest { + @Autowired ProtoSignature signer; + + private static final Logger logger = LoggerFactory.getLogger(GaenControllerTest.class); + + @Test + @Transactional + public void zipContainsFiles() throws Exception { + var clockStartingAtMidnight = + Clock.offset(Clock.systemUTC(), UTCInstant.now().getDuration(UTCInstant.today()).negated()); + UTCInstant.setClock(clockStartingAtMidnight); + var now = UTCInstant.now(); + var midnight = now.atStartOfDay(); + + // insert two times 5 keys per day for the last 14 days. the second batch has a + // different received at timestamp. (+6 hours) + insertNKeysPerDayInInterval(14, midnight.minusDays(4), now, now.minusDays(1)); + + insertNKeysPerDayInInterval(14, midnight.minusDays(4), now, now.minusDays(12)); + + // request the keys with date date 1 day ago. no publish until. + MockHttpServletResponse response = + mockMvc + .perform( + get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) + .header("User-Agent", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29")) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse(); + + Long publishedUntil = Long.parseLong(response.getHeader("X-PUBLISHED-UNTIL")); + assertTrue(publishedUntil < now.getTimestamp()); + + verifyZipResponse(response, 20); + + // request again the keys with date date 1 day ago. with publish until, so that + // we only get the second batch. + var bucketAfterSecondRelease = + Duration.ofMillis(midnight.getTimestamp()) + .minusDays(1) + .plusHours(12) + .dividedBy(Duration.ofHours(2)) + * 2 + * 60 + * 60 + * 1000; + MockHttpServletResponse responseWithPublishedAfter = + mockMvc + .perform( + get("/v1/gaen/exposed/" + midnight.minusDays(8).getTimestamp()) + .header("User-Agent", "ch.admin.bag.dp3t.dev;1.0.7;1595591959493;Android;29") + .param("publishedafter", Long.toString(bucketAfterSecondRelease))) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse(); + + // we always have 10 + verifyZipResponse(responseWithPublishedAfter, 10); + UTCInstant.resetClock(); + } + + private void verifyZipResponse(MockHttpServletResponse response, int expectKeyCount) + throws Exception { + ByteArrayInputStream baisOuter = new ByteArrayInputStream(response.getContentAsByteArray()); + ZipInputStream zipOuter = new ZipInputStream(baisOuter); + ZipEntry entry = zipOuter.getNextEntry(); + boolean foundData = false; + boolean foundSignature = false; + + byte[] signatureProto = null; + byte[] exportBin = null; + byte[] keyProto = null; + + while (entry != null) { + if (entry.getName().equals("export.bin")) { + foundData = true; + exportBin = zipOuter.readAllBytes(); + keyProto = new byte[exportBin.length - 16]; + System.arraycopy(exportBin, 16, keyProto, 0, keyProto.length); + } + if (entry.getName().equals("export.sig")) { + foundSignature = true; + signatureProto = zipOuter.readAllBytes(); + } + entry = zipOuter.getNextEntry(); + } + + assertTrue(foundData); + assertTrue(foundSignature); + + var list = TemporaryExposureKeyFormat.TEKSignatureList.parseFrom(signatureProto); + var export = TemporaryExposureKeyFormat.TemporaryExposureKeyExport.parseFrom(keyProto); + for (var key : export.getKeysList()) { + assertNotEquals(0, key.getRollingPeriod()); + } + var sig = list.getSignatures(0); + java.security.Signature signatureVerifier = + java.security.Signature.getInstance(sig.getSignatureInfo().getSignatureAlgorithm().trim()); + signatureVerifier.initVerify(signer.getPublicKey()); + + signatureVerifier.update(exportBin); + assertTrue(signatureVerifier.verify(sig.getSignature().toByteArray())); + assertEquals(expectKeyCount, export.getKeysCount()); + } + + private void insertNKeysPerDayInIntervalWithDebugFlag( + int n, UTCInstant start, UTCInstant end, UTCInstant receivedAt, boolean debug) + throws Exception { + var current = start; + Map rollingToCount = new HashMap<>(); + while (current.isBeforeEpochMillisOf(end)) { + List keys = new ArrayList<>(); + SecureRandom random = new SecureRandom(); + int lastRolling = (int) start.get10MinutesSince1970(); + for (int i = 0; i < n; i++) { + GaenKey key = new GaenKey(); + byte[] keyBytes = new byte[16]; + random.nextBytes(keyBytes); + key.setKeyData(Base64.getEncoder().encodeToString(keyBytes)); + key.setRollingPeriod(144); + logger.info("Rolling Start number: " + lastRolling); + key.setRollingStartNumber(lastRolling); + key.setTransmissionRiskLevel(1); + key.setFake(0); + keys.add(key); + + Integer count = rollingToCount.get(lastRolling); + if (count == null) { + count = 0; + } + count = count + 1; + rollingToCount.put(lastRolling, count); + + lastRolling -= Duration.ofDays(1).dividedBy(Duration.ofMinutes(10)); + } + if (debug) { + testGaenDataService.upsertExposeesDebug(keys, receivedAt); + } else { + testGaenDataService.upsertExposees(keys, receivedAt); + } + current = current.plusDays(1); + } + for (Entry entry : rollingToCount.entrySet()) { + logger.info( + "Rolling start number: " + + entry.getKey() + + " -> count: " + + entry.getValue() + + " (received at: " + + receivedAt.toString() + + ")"); + } + } + + private void insertNKeysPerDayInInterval( + int n, UTCInstant start, UTCInstant end, UTCInstant receivedAt) throws Exception { + insertNKeysPerDayInIntervalWithDebugFlag(n, start, end, receivedAt, false); + } +} 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; + } + } +}