diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java new file mode 100644 index 000000000..e8fd6ebdc --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java @@ -0,0 +1,7 @@ +package org.whispersystems.textsecuregcm.util.ua; + +public enum ClientPlatform { + ANDROID, + DESKTOP, + IOS; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java new file mode 100644 index 000000000..ee6f402d9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java @@ -0,0 +1,15 @@ +package org.whispersystems.textsecuregcm.util.ua; + +public class UnrecognizedUserAgentException extends Exception { + + public UnrecognizedUserAgentException() { + } + + public UnrecognizedUserAgentException(final String message) { + super(message); + } + + public UnrecognizedUserAgentException(final Throwable cause) { + super(cause); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java new file mode 100644 index 000000000..222fdebc7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java @@ -0,0 +1,59 @@ +package org.whispersystems.textsecuregcm.util.ua; + +import com.vdurmont.semver4j.Semver; + +import java.util.Objects; +import java.util.Optional; + +public class UserAgent { + + private final ClientPlatform platform; + private final Semver version; + private final String additionalSpecifiers; + + public UserAgent(final ClientPlatform platform, final Semver version) { + this(platform, version, null); + } + + public UserAgent(final ClientPlatform platform, final Semver version, final String additionalSpecifiers) { + this.platform = platform; + this.version = version; + this.additionalSpecifiers = additionalSpecifiers; + } + + public ClientPlatform getPlatform() { + return platform; + } + + public Semver getVersion() { + return version; + } + + public Optional getAdditionalSpecifiers() { + return Optional.ofNullable(additionalSpecifiers); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final UserAgent userAgent = (UserAgent)o; + return platform == userAgent.platform && + version.equals(userAgent.version) && + Objects.equals(additionalSpecifiers, userAgent.additionalSpecifiers); + } + + @Override + public int hashCode() { + return Objects.hash(platform, version, additionalSpecifiers); + } + + @Override + public String toString() { + return "UserAgent{" + + "platform=" + platform + + ", version=" + version + + ", additionalSpecifiers='" + additionalSpecifiers + '\'' + + '}'; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java new file mode 100644 index 000000000..f8e94e38b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java @@ -0,0 +1,81 @@ +package org.whispersystems.textsecuregcm.util.ua; + +import com.google.common.annotations.VisibleForTesting; +import com.vdurmont.semver4j.Semver; +import org.apache.commons.lang3.StringUtils; + +import java.util.EnumMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class UserAgentUtil { + + private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE); + + private static final Map LEGACY_PATTERNS_BY_PLATFORM = new EnumMap<>(ClientPlatform.class); + + static { + LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.ANDROID, Pattern.compile("^Signal-Android ([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE)); + LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.DESKTOP, Pattern.compile("^Signal Desktop (.+)$", Pattern.CASE_INSENSITIVE)); + LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.IOS, Pattern.compile("^Signal/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE)); + } + + public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException { + if (StringUtils.isBlank(userAgentString)) { + throw new UnrecognizedUserAgentException("User-Agent string is blank"); + } + + try { + final UserAgent standardUserAgent = parseStandardUserAgentString(userAgentString); + + if (standardUserAgent != null) { + return standardUserAgent; + } + + final UserAgent legacyUserAgent = parseLegacyUserAgentString(userAgentString); + + if (legacyUserAgent != null) { + return legacyUserAgent; + } + } catch (final Exception e) { + throw new UnrecognizedUserAgentException(e); + } + + throw new UnrecognizedUserAgentException(); + } + + @VisibleForTesting + static UserAgent parseStandardUserAgentString(final String userAgentString) { + final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString); + + if (matcher.matches()) { + return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4))); + } + + return null; + } + + @VisibleForTesting + static UserAgent parseLegacyUserAgentString(final String userAgentString) { + for (final Map.Entry entry : LEGACY_PATTERNS_BY_PLATFORM.entrySet()) { + final ClientPlatform platform = entry.getKey(); + final Pattern pattern = entry.getValue(); + final Matcher matcher = pattern.matcher(userAgentString); + + if (matcher.matches()) { + final UserAgent userAgent; + + if (matcher.groupCount() > 1) { + userAgent = new UserAgent(platform, new Semver(matcher.group(1)), StringUtils.stripToNull(matcher.group(matcher.groupCount()))); + } else { + userAgent = new UserAgent(platform, new Semver(matcher.group(1))); + } + + return userAgent; + } + } + + return null; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java new file mode 100644 index 000000000..556d5e345 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java @@ -0,0 +1,74 @@ +package org.whispersystems.textsecuregcm.util.ua; + +import com.vdurmont.semver4j.Semver; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; + +import static org.junit.Assert.*; + +@RunWith(JUnitParamsRunner.class) +public class UserAgentUtilTest { + + @Test + @Parameters(method = "argumentsForTestParseUserAgentString") + public void testParseUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) throws UnrecognizedUserAgentException { + assertEquals(expectedUserAgent, UserAgentUtil.parseUserAgentString(userAgentString)); + } + + private static Object argumentsForTestParseUserAgentString() { + return new Object[] { + new Object[] { "Signal-Android/4.68.3 Android/25", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25") }, + new Object[] { "Signal-Android 4.53.7 (Android 8.1)", new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)") }, + }; + } + + @Test + @Parameters(method = "argumentsForTestParseBogusUserAgentString") + public void testParseBogusUserAgentString(final String userAgentString) { + assertThrows(UnrecognizedUserAgentException.class, () -> UserAgentUtil.parseUserAgentString(userAgentString)); + } + + private static Object argumentsForTestParseBogusUserAgentString() { + return new Object[] { + null, + "This is obviously not a reasonable User-Agent string.", + "Signal-Android/4.6-8.3.unreasonableversionstring-17" + }; + } + + @Test + @Parameters(method = "argumentsForTestParseStandardUserAgentString") + public void testParseStandardUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) { + assertEquals(expectedUserAgent, UserAgentUtil.parseStandardUserAgentString(userAgentString)); + } + + private static Object argumentsForTestParseStandardUserAgentString() { + return new Object[] { + new Object[] { "This is obviously not a reasonable User-Agent string.", null }, + new Object[] { "Signal-Android/4.68.3 Android/25", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25") }, + new Object[] { "Signal-Android/4.68.3", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3")) }, + new Object[] { "Signal-Desktop/1.32.0-beta.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3")) }, + new Object[] { "Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)") }, + new Object[] { "Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0")) } + }; + } + + @Test + @Parameters(method = "argumentsForTestParseLegacyUserAgentString") + public void testParseLegacyUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) { + assertEquals(expectedUserAgent, UserAgentUtil.parseLegacyUserAgentString(userAgentString)); + } + + private static Object argumentsForTestParseLegacyUserAgentString() { + return new Object[] { + new Object[] { "This is obviously not a reasonable User-Agent string.", null }, + new Object[] { "Signal-Android 4.53.7 (Android 8.1)", new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)") }, + new Object[] { "Signal Desktop 1.2.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3")) }, + new Object[] { "Signal Desktop 1.32.0-beta.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3")) }, + new Object[] { "Signal/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)") } + }; + } +}