├── .github ├── stale.yml ├── workflows │ └── test.yml └── dependabot.yml ├── .mvn ├── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── jgitver.config.xml └── extensions.xml ├── src ├── main │ ├── resources │ │ └── META-INF │ │ │ └── services │ │ │ ├── io.dropwizard.metrics.common.ReporterFactory │ │ │ └── io.dropwizard.logging.common.AppenderFactory │ ├── java │ │ └── org │ │ │ └── signal │ │ │ └── storageservice │ │ │ ├── configuration │ │ │ ├── WarmupConfiguration.java │ │ │ ├── AuthenticationConfiguration.java │ │ │ ├── ZkConfiguration.java │ │ │ ├── CdnConfiguration.java │ │ │ ├── BigTableConfiguration.java │ │ │ ├── DatadogConfiguration.java │ │ │ └── GroupConfiguration.java │ │ │ ├── util │ │ │ ├── ua │ │ │ │ ├── ClientPlatform.java │ │ │ │ ├── UserAgent.java │ │ │ │ ├── UnrecognizedUserAgentException.java │ │ │ │ └── UserAgentUtil.java │ │ │ ├── ExactlySizeValidatorForArraysOfByte.java │ │ │ ├── ExactlySizeValidatorForString.java │ │ │ ├── ExactlySizeValidatorForCollection.java │ │ │ ├── HeaderUtils.java │ │ │ ├── UriInfoUtil.java │ │ │ ├── Pair.java │ │ │ ├── UncaughtExceptionHandler.java │ │ │ ├── SystemMapper.java │ │ │ ├── ExactlySizeValidator.java │ │ │ ├── CollectionUtil.java │ │ │ ├── ByteArrayAdapter.java │ │ │ ├── logging │ │ │ │ └── LoggingUnhandledExceptionMapper.java │ │ │ ├── ExactlySize.java │ │ │ ├── HexByteArrayAdapter.java │ │ │ ├── HostSupplier.java │ │ │ ├── Util.java │ │ │ └── Conversions.java │ │ │ ├── metrics │ │ │ ├── StorageMetrics.java │ │ │ ├── FileDescriptorGauge.java │ │ │ ├── CpuUsageGauge.java │ │ │ ├── NetworkGauge.java │ │ │ ├── FreeMemoryGauge.java │ │ │ ├── NetworkSentGauge.java │ │ │ ├── NetworkReceivedGauge.java │ │ │ ├── UserAgentTagUtil.java │ │ │ ├── SignalDatadogReporterFactory.java │ │ │ ├── MetricsUtil.java │ │ │ ├── LogstashTcpSocketAppenderFactory.java │ │ │ └── MetricsHttpChannelListener.java │ │ │ ├── controllers │ │ │ ├── HealthCheckController.java │ │ │ ├── ReadinessController.java │ │ │ ├── GroupsV1Controller.java │ │ │ └── StorageController.java │ │ │ ├── providers │ │ │ ├── CompletionExceptionMapper.java │ │ │ ├── NoUnknownFields.java │ │ │ ├── NoUnknownFieldsValidator.java │ │ │ ├── InvalidProtocolBufferExceptionMapper.java │ │ │ ├── ProtocolBufferValidationErrorMessageBodyWriter.java │ │ │ ├── ProtocolBufferMediaType.java │ │ │ └── ProtocolBufferMessageBodyProvider.java │ │ │ ├── auth │ │ │ ├── User.java │ │ │ ├── UserAuthenticator.java │ │ │ ├── ExternalGroupCredentialGenerator.java │ │ │ ├── GroupUserAuthenticator.java │ │ │ ├── GroupUser.java │ │ │ └── ExternalServiceCredentialValidator.java │ │ │ ├── s3 │ │ │ ├── Base16Lower.java │ │ │ ├── PolicySigner.java │ │ │ └── PostPolicyGenerator.java │ │ │ ├── filters │ │ │ └── TimestampResponseFilter.java │ │ │ ├── StorageServiceConfiguration.java │ │ │ ├── storage │ │ │ ├── GroupsTable.java │ │ │ ├── GroupsManager.java │ │ │ ├── StorageManager.java │ │ │ ├── GroupLogTable.java │ │ │ ├── StorageManifestsTable.java │ │ │ ├── Table.java │ │ │ └── StorageItemsTable.java │ │ │ └── StorageService.java │ ├── java-templates │ │ └── org │ │ │ └── signal │ │ │ └── storageservice │ │ │ └── StorageServiceVersion.java │ └── proto │ │ ├── StorageService.proto │ │ └── Groups.proto └── test │ ├── resources │ └── logback-test.xml │ └── java │ └── org │ └── signal │ └── storageservice │ ├── storage │ └── BigtableEmulatorExtension.java │ ├── auth │ ├── GroupUserTest.java │ ├── ExternalGroupCredentialGeneratorTest.java │ └── GroupUserAuthenticatorTest.java │ ├── controllers │ └── GroupsControllerPhoneNumberPrivacyTest.java │ ├── filters │ └── TimestampResponseFilterTest.java │ ├── util │ ├── TestClock.java │ └── ua │ │ └── UserAgentUtilTest.java │ └── metrics │ ├── UserAgentTagUtilTest.java │ └── MetricsHttpChannelListenerTest.java ├── .gitignore ├── README.md ├── Makefile └── mvnw.cmd /.github/stale.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/signalapp/storage-service/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/io.dropwizard.metrics.common.ReporterFactory: -------------------------------------------------------------------------------- 1 | org.signal.storageservice.metrics.SignalDatadogReporterFactory 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.vscode 3 | /config 4 | /target 5 | *.iml 6 | .classpath 7 | .settings 8 | .project 9 | .DS_Store 10 | .java-version 11 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/io.dropwizard.logging.common.AppenderFactory: -------------------------------------------------------------------------------- 1 | org.signal.storageservice.metrics.LogstashTcpSocketAppenderFactory 2 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/WarmupConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.signal.storageservice.configuration; 2 | 3 | import jakarta.validation.constraints.Positive; 4 | 5 | public record WarmupConfiguration( 6 | // the number of times warmup logic should run 7 | @Positive Integer count 8 | ) { } 9 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ua/ClientPlatform.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.ua; 7 | 8 | public enum ClientPlatform { 9 | ANDROID, 10 | DESKTOP, 11 | IOS; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/StorageMetrics.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | public class StorageMetrics { 9 | public static final String NAME = "storage_metrics"; 10 | } 11 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.mvn/jgitver.config.xml: -------------------------------------------------------------------------------- 1 | 4 | true 5 | main 6 | 7 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ua/UserAgent.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.ua; 7 | 8 | import com.vdurmont.semver4j.Semver; 9 | 10 | import javax.annotation.Nullable; 11 | 12 | public record UserAgent(ClientPlatform platform, Semver version, @Nullable String additionalSpecifiers) { 13 | } 14 | -------------------------------------------------------------------------------- /.mvn/extensions.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | fr.brouillard.oss 5 | jgitver-maven-plugin 6 | 1.9.0 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/java-templates/org/signal/storageservice/StorageServiceVersion.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice; 7 | 8 | public class StorageServiceVersion { 9 | 10 | private static final String VERSION = "${project.version}"; 11 | 12 | public static String getServiceVersion() { 13 | return VERSION; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/controllers/HealthCheckController.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.controllers; 7 | 8 | import jakarta.ws.rs.GET; 9 | import jakarta.ws.rs.Path; 10 | 11 | @Path("/ping") 12 | public class HealthCheckController { 13 | 14 | @GET 15 | public String isAlive() { 16 | return "Pong"; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ExactlySizeValidatorForArraysOfByte.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | public class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator { 9 | 10 | @Override 11 | protected int size(final byte[] value) { 12 | return value == null ? 0 : value.length; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ExactlySizeValidatorForString.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | 9 | public class ExactlySizeValidatorForString extends ExactlySizeValidator { 10 | 11 | @Override 12 | protected int size(final String value) { 13 | return value == null ? 0 : value.length(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ExactlySizeValidatorForCollection.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import java.util.Collection; 9 | 10 | public class ExactlySizeValidatorForCollection extends ExactlySizeValidator> { 11 | 12 | @Override 13 | protected int size(final Collection value) { 14 | return value == null ? 0 : value.size(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/HeaderUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | public final class HeaderUtils { 12 | 13 | private static final Logger logger = LoggerFactory.getLogger(HeaderUtils.class); 14 | 15 | public static final String TIMESTAMP_HEADER = "X-Signal-Timestamp"; 16 | 17 | private HeaderUtils() { 18 | // utility class 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ua/UnrecognizedUserAgentException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.ua; 7 | 8 | public class UnrecognizedUserAgentException extends Exception { 9 | 10 | public UnrecognizedUserAgentException() { 11 | } 12 | 13 | public UnrecognizedUserAgentException(final String message) { 14 | super(message); 15 | } 16 | 17 | public UnrecognizedUserAgentException(final Throwable cause) { 18 | super(cause); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/FileDescriptorGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | 9 | import com.codahale.metrics.Gauge; 10 | 11 | import java.io.File; 12 | 13 | public class FileDescriptorGauge implements Gauge { 14 | @Override 15 | public Integer getValue() { 16 | File file = new File("/proc/self/fd"); 17 | 18 | if (file.isDirectory() && file.exists()) { 19 | return file.list().length; 20 | } 21 | 22 | return 0; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/CpuUsageGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.codahale.metrics.Gauge; 9 | import com.sun.management.OperatingSystemMXBean; 10 | 11 | import java.lang.management.ManagementFactory; 12 | 13 | public class CpuUsageGauge implements Gauge { 14 | @Override 15 | public Integer getValue() { 16 | OperatingSystemMXBean mbean = (OperatingSystemMXBean) 17 | ManagementFactory.getOperatingSystemMXBean(); 18 | 19 | return (int) Math.ceil(mbean.getCpuLoad() * 100); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/UriInfoUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import org.glassfish.jersey.server.ExtendedUriInfo; 9 | 10 | public class UriInfoUtil { 11 | 12 | public static String getPathTemplate(final ExtendedUriInfo uriInfo) { 13 | final StringBuilder pathBuilder = new StringBuilder(); 14 | 15 | for (int i = uriInfo.getMatchedTemplates().size() - 1; i >= 0; i--) { 16 | pathBuilder.append(uriInfo.getMatchedTemplates().get(i).getTemplate()); 17 | } 18 | 19 | return pathBuilder.toString(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/AuthenticationConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | import org.apache.commons.codec.DecoderException; 10 | import org.apache.commons.codec.binary.Hex; 11 | 12 | import jakarta.validation.constraints.NotEmpty; 13 | 14 | public class AuthenticationConfiguration { 15 | 16 | @JsonProperty 17 | @NotEmpty 18 | private String key; 19 | 20 | public byte[] getKey() throws DecoderException { 21 | return Hex.decodeHex(key); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | container: ubuntu:22.04 9 | 10 | steps: 11 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 12 | - name: Set up JDK 21 13 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 14 | with: 15 | distribution: 'temurin' 16 | java-version: 21 17 | cache: 'maven' 18 | env: 19 | # work around an issue with actions/runner setting an incorrect HOME in containers, which breaks maven caching 20 | # https://github.com/actions/setup-java/issues/356 21 | HOME: /root 22 | - name: Build with Maven 23 | run: ./mvnw -e -B verify 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Signal Storage Service 2 | ====================== 3 | 4 | How to Build 5 | ------------ 6 | 7 | ```shell script 8 | $ ./mvnw clean test 9 | ``` 10 | 11 | Contributing bug reports 12 | ------------------------ 13 | 14 | We use [GitHub][github issues] for bug tracking. Security issues should be sent to security@signal.org. 15 | 16 | Help 17 | ---- 18 | 19 | We cannot provide direct technical support. Get help running this software in your own environment in our [unofficial community forum][community forum]. 20 | 21 | License 22 | ------- 23 | 24 | Copyright 2020 Signal Messenger, LLC 25 | 26 | Licensed under the [GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.txt) 27 | 28 | [github issues]: https://github.com/signalapp/storage-service/issues 29 | [community forum]: https://community.signalusers.org 30 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/Pair.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import static com.google.common.base.Objects.equal; 9 | 10 | public class Pair { 11 | private final T1 v1; 12 | private final T2 v2; 13 | 14 | public Pair(T1 v1, T2 v2) { 15 | this.v1 = v1; 16 | this.v2 = v2; 17 | } 18 | 19 | public T1 first(){ 20 | return v1; 21 | } 22 | 23 | public T2 second(){ 24 | return v2; 25 | } 26 | 27 | public boolean equals(Object o) { 28 | return o instanceof Pair && 29 | equal(((Pair) o).first(), first()) && 30 | equal(((Pair) o).second(), second()); 31 | } 32 | 33 | public int hashCode() { 34 | return first().hashCode() ^ second().hashCode(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/ZkConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 10 | import com.fasterxml.jackson.databind.annotation.JsonSerialize; 11 | 12 | import org.signal.storageservice.util.ByteArrayAdapter; 13 | 14 | import jakarta.validation.constraints.NotNull; 15 | 16 | public class ZkConfiguration { 17 | 18 | @JsonProperty 19 | @JsonSerialize(using = ByteArrayAdapter.Serializing.class) 20 | @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) 21 | @NotNull 22 | private byte[] serverSecret; 23 | 24 | public byte[] getServerSecret() { 25 | return serverSecret; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: / 5 | ignore: 6 | - dependency-name: "*" 7 | update-types: 8 | - version-update:semver-major 9 | schedule: 10 | interval: monthly 11 | groups: 12 | minor-java-dependencies: 13 | # group minor/patch Maven updates into one PR; do not update major versions 14 | # (too likely to break something) 15 | update-types: 16 | - minor 17 | - patch 18 | - package-ecosystem: github-actions 19 | directory: / 20 | schedule: 21 | interval: monthly 22 | groups: 23 | minor-actions-dependencies: 24 | # group minor/patch GHA updates into one PR; keep separate PRs for 25 | # major version updates so we can review them carefully 26 | update-types: 27 | - minor 28 | - patch 29 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/UncaughtExceptionHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import javax.annotation.Nullable; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | public class UncaughtExceptionHandler { 13 | 14 | private static final Logger logger = LoggerFactory.getLogger(UncaughtExceptionHandler.class); 15 | 16 | public static void register() { 17 | 18 | @Nullable final Thread.UncaughtExceptionHandler current = Thread.getDefaultUncaughtExceptionHandler(); 19 | 20 | if (current != null) { 21 | logger.warn("Uncaught exception handler already exists: {}", current); 22 | return; 23 | } 24 | 25 | Thread.setDefaultUncaughtExceptionHandler((t, e) -> logger.error("Uncaught exception on thread {}", t, e)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/proto/StorageService.proto: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | syntax = "proto3"; 7 | 8 | package signal; 9 | 10 | option java_package = "org.signal.storageservice.storage.protos.contacts"; 11 | option java_outer_classname = "StorageProtos"; 12 | option java_multiple_files = true; 13 | 14 | message StorageManifest { 15 | uint64 version = 1; 16 | bytes value = 2; 17 | } 18 | 19 | message StorageItem { 20 | bytes key = 1; 21 | bytes value = 2; 22 | } 23 | 24 | message StorageItems { 25 | repeated StorageItem contacts = 1; 26 | } 27 | 28 | message WriteOperation { 29 | StorageManifest manifest = 1; 30 | repeated StorageItem insertItem = 2; 31 | repeated bytes deleteKey = 3; 32 | bool clearAll = 4; 33 | } 34 | 35 | message ReadOperation { 36 | repeated bytes readKey = 1; 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/SystemMapper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import com.fasterxml.jackson.annotation.JsonAutoDetect; 9 | import com.fasterxml.jackson.annotation.PropertyAccessor; 10 | import com.fasterxml.jackson.databind.DeserializationFeature; 11 | import com.fasterxml.jackson.databind.ObjectMapper; 12 | 13 | public class SystemMapper { 14 | 15 | private static final ObjectMapper mapper = new ObjectMapper(); 16 | 17 | static { 18 | mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); 19 | mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); 20 | mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 21 | } 22 | 23 | public static ObjectMapper getMapper() { 24 | return mapper; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ExactlySizeValidator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import java.util.Arrays; 9 | import java.util.Set; 10 | import java.util.stream.Collectors; 11 | import jakarta.validation.ConstraintValidator; 12 | import jakarta.validation.ConstraintValidatorContext; 13 | 14 | public abstract class ExactlySizeValidator implements ConstraintValidator { 15 | 16 | private Set permittedSizes; 17 | 18 | @Override 19 | public void initialize(ExactlySize annotation) { 20 | permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet()); 21 | } 22 | 23 | @Override 24 | public boolean isValid(T value, ConstraintValidatorContext context) { 25 | return permittedSizes.contains(size(value)); 26 | } 27 | 28 | protected abstract int size(T value); 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/CdnConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | 10 | import jakarta.validation.constraints.NotEmpty; 11 | 12 | public class CdnConfiguration { 13 | @NotEmpty 14 | @JsonProperty 15 | private String accessKey; 16 | 17 | @NotEmpty 18 | @JsonProperty 19 | private String accessSecret; 20 | 21 | @NotEmpty 22 | @JsonProperty 23 | private String bucket; 24 | 25 | @NotEmpty 26 | @JsonProperty 27 | private String region; 28 | 29 | public String getAccessKey() { 30 | return accessKey; 31 | } 32 | 33 | public String getAccessSecret() { 34 | return accessSecret; 35 | } 36 | 37 | public String getBucket() { 38 | return bucket; 39 | } 40 | 41 | public String getRegion() { 42 | return region; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/CompletionExceptionMapper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.providers; 7 | 8 | import java.util.concurrent.CompletionException; 9 | import jakarta.ws.rs.core.Context; 10 | import jakarta.ws.rs.core.Response; 11 | import jakarta.ws.rs.ext.ExceptionMapper; 12 | import jakarta.ws.rs.ext.Provider; 13 | import org.glassfish.jersey.spi.ExceptionMappers; 14 | 15 | @Provider 16 | public class CompletionExceptionMapper implements ExceptionMapper { 17 | 18 | @Context 19 | private ExceptionMappers exceptionMappers; 20 | 21 | @Override 22 | public Response toResponse(final CompletionException exception) { 23 | final Throwable cause = exception.getCause(); 24 | 25 | if (cause != null) { 26 | return exceptionMappers.findMapping(cause).toResponse(cause); 27 | } 28 | 29 | return Response.serverError().build(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/NoUnknownFields.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.providers; 7 | 8 | import jakarta.validation.Constraint; 9 | import jakarta.validation.Payload; 10 | import java.lang.annotation.Documented; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.Target; 13 | 14 | import static java.lang.annotation.ElementType.FIELD; 15 | import static java.lang.annotation.ElementType.PARAMETER; 16 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 17 | 18 | @Documented 19 | @Retention(RUNTIME) 20 | @Target({FIELD, PARAMETER}) 21 | @Constraint(validatedBy = {NoUnknownFieldsValidator.class}) 22 | public @interface NoUnknownFields { 23 | String message() default "{org.signal.storageservice.providers.NoUnknownFields.message}"; 24 | Class[] groups() default {}; 25 | Class[] payload() default {}; 26 | 27 | boolean recursive() default true; 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/CollectionUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import java.util.Collection; 9 | import java.util.HashSet; 10 | import java.util.Set; 11 | import java.util.stream.Stream; 12 | 13 | public class CollectionUtil { 14 | 15 | public static boolean containsAny(Collection first, Collection second) { 16 | return containsAny(new HashSet<>(first), second); 17 | } 18 | 19 | public static boolean containsAny(Set first, Collection second) { 20 | for (T item : second) { 21 | if (first.contains(item)) return true; 22 | } 23 | 24 | return false; 25 | } 26 | 27 | public static boolean containsDuplicates(Collection items) { 28 | return containsDuplicates(items.stream()); 29 | } 30 | 31 | public static boolean containsDuplicates(Stream stream) { 32 | Set contents = new HashSet<>(); 33 | return stream.anyMatch(x -> !contents.add(x)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | configuration_repo = ../configuration 2 | configuration_files = config/production.yml config/staging.yml config/staging-build.properties config/production-build.properties 3 | 4 | .NOTPARALLEL: 5 | .PHONY: help copy-config deploy-staging deploy-production 6 | 7 | help: 8 | @echo "This makefile defines the following targets:" 9 | @echo " * help: Show this message" 10 | @echo " * copy-config: Copies configuration from the configuration repo into the config directory" 11 | @echo " + configuration_repo variable may be set to control where to read from" 12 | @echo " (defaults to $(configuration_repo))" 13 | @echo " * deploy-staging: Builds and pushes a staging image" 14 | @echo " * deploy-production: Builds and pushes a production image" 15 | config: 16 | mkdir -p config 17 | mkdir -p config/appengine-production 18 | mkdir -p config/appengine-staging 19 | $(configuration_files): config/%: $(configuration_repo)/storage/% | config 20 | cp "$<" "$@" 21 | copy-config: $(configuration_files) 22 | deploy-staging: copy-config 23 | ./mvnw clean deploy -Denv=staging 24 | deploy-production: copy-config 25 | ./mvnw clean deploy -Denv=production 26 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/User.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import com.fasterxml.jackson.annotation.JsonIgnore; 9 | 10 | import javax.security.auth.Subject; 11 | import java.security.Principal; 12 | import java.util.UUID; 13 | 14 | public class User implements Principal { 15 | 16 | private final UUID uuid; 17 | 18 | public User(UUID uuid) { 19 | this.uuid = uuid; 20 | } 21 | 22 | public UUID getUuid() { 23 | return uuid; 24 | } 25 | 26 | // Principal implementation 27 | 28 | @JsonIgnore 29 | @Override 30 | public String getName() { 31 | return null; 32 | } 33 | 34 | @JsonIgnore 35 | @Override 36 | public boolean implies(Subject subject) { 37 | return false; 38 | } 39 | 40 | @Override 41 | public boolean equals(Object other) { 42 | if (other == null ) return false; 43 | if (!(other instanceof User)) return false; 44 | 45 | User that = (User)other; 46 | 47 | return this.uuid.equals(that.uuid); 48 | } 49 | 50 | @Override 51 | public int hashCode() { 52 | return uuid.hashCode(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar 19 | distributionSha256Sum=4ec3f26fb1a692473aea0235c300bd20f0f9fe741947c82c1234cefd76ac3a3c 20 | wrapperSha256Sum=3d8f20ce6103913be8b52aef6d994e0c54705fb527324ceb9b835b338739c7a8 21 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/storage/BigtableEmulatorExtension.java: -------------------------------------------------------------------------------- 1 | package org.signal.storageservice.storage; 2 | 3 | import com.google.cloud.bigtable.emulator.v2.Emulator; 4 | import org.junit.jupiter.api.extension.AfterEachCallback; 5 | import org.junit.jupiter.api.extension.BeforeEachCallback; 6 | import org.junit.jupiter.api.extension.ExtensionContext; 7 | 8 | /** 9 | * A JUnit 5 extension that manages a Bigtable {@link Emulator} instance. Modeled after 10 | * {@code com.google.cloud.bigtable.emulator.v2.BigtableEmulatorRule}. 11 | */ 12 | public class BigtableEmulatorExtension implements BeforeEachCallback, AfterEachCallback { 13 | 14 | private Emulator emulator; 15 | 16 | static BigtableEmulatorExtension create() { 17 | return new BigtableEmulatorExtension(); 18 | } 19 | 20 | private BigtableEmulatorExtension() { 21 | 22 | } 23 | 24 | @Override 25 | public void beforeEach(final ExtensionContext context) throws Exception { 26 | emulator = Emulator.createBundled(); 27 | emulator.start(); 28 | } 29 | 30 | @Override 31 | public void afterEach(final ExtensionContext context) throws Exception { 32 | emulator.stop(); 33 | emulator = null; 34 | } 35 | 36 | public int getPort() { 37 | return emulator.getPort(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/NetworkGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.codahale.metrics.Gauge; 9 | 10 | import org.signal.storageservice.util.Pair; 11 | 12 | import java.io.BufferedReader; 13 | import java.io.File; 14 | import java.io.FileReader; 15 | import java.io.IOException; 16 | 17 | public abstract class NetworkGauge implements Gauge { 18 | 19 | protected Pair getSentReceived() throws IOException { 20 | File proc = new File("/proc/net/dev"); 21 | try (BufferedReader reader = new BufferedReader(new FileReader(proc))) { 22 | reader.readLine(); // header 23 | reader.readLine(); // header2 24 | 25 | long bytesSent = 0; 26 | long bytesReceived = 0; 27 | 28 | String interfaceStats; 29 | 30 | while ((interfaceStats = reader.readLine()) != null) { 31 | String[] stats = interfaceStats.split("\\s+"); 32 | 33 | if (!stats[1].equals("lo:")) { 34 | bytesReceived += Long.parseLong(stats[2]); 35 | bytesSent += Long.parseLong(stats[10]); 36 | } 37 | } 38 | 39 | return new Pair<>(bytesSent, bytesReceived); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/FreeMemoryGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.codahale.metrics.Gauge; 9 | import com.sun.management.OperatingSystemMXBean; 10 | import io.micrometer.core.instrument.MeterRegistry; 11 | import io.micrometer.core.instrument.binder.MeterBinder; 12 | 13 | import java.lang.management.ManagementFactory; 14 | 15 | import static org.signal.storageservice.metrics.MetricsUtil.name; 16 | 17 | public class FreeMemoryGauge implements Gauge, MeterBinder { 18 | 19 | private final OperatingSystemMXBean operatingSystemMXBean; 20 | 21 | public FreeMemoryGauge() { 22 | this.operatingSystemMXBean = (com.sun.management.OperatingSystemMXBean) 23 | ManagementFactory.getOperatingSystemMXBean(); 24 | } 25 | 26 | @Override 27 | public Long getValue() { 28 | return operatingSystemMXBean.getFreeMemorySize(); 29 | } 30 | 31 | @Override 32 | public void bindTo(final MeterRegistry registry) { 33 | io.micrometer.core.instrument.Gauge.builder(name(FreeMemoryGauge.class, "freeMemory"), operatingSystemMXBean, 34 | OperatingSystemMXBean::getFreeMemorySize) 35 | .register(registry); 36 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ua/UserAgentUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.ua; 7 | 8 | import com.vdurmont.semver4j.Semver; 9 | import java.util.regex.Matcher; 10 | import java.util.regex.Pattern; 11 | import org.apache.commons.lang3.StringUtils; 12 | 13 | public class UserAgentUtil { 14 | 15 | private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE); 16 | 17 | public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException { 18 | if (StringUtils.isBlank(userAgentString)) { 19 | throw new UnrecognizedUserAgentException("User-Agent string is blank"); 20 | } 21 | 22 | try { 23 | final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString); 24 | 25 | if (matcher.matches()) { 26 | return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4))); 27 | } 28 | } catch (final Exception e) { 29 | throw new UnrecognizedUserAgentException(e); 30 | } 31 | 32 | throw new UnrecognizedUserAgentException(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/BigTableConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | 10 | import jakarta.validation.constraints.NotEmpty; 11 | 12 | public class BigTableConfiguration { 13 | 14 | @JsonProperty 15 | @NotEmpty 16 | private String projectId; 17 | 18 | @JsonProperty 19 | @NotEmpty 20 | private String instanceId; 21 | 22 | @JsonProperty 23 | @NotEmpty 24 | private String contactManifestsTableId; 25 | 26 | @JsonProperty 27 | @NotEmpty 28 | private String contactsTableId; 29 | 30 | @JsonProperty 31 | @NotEmpty 32 | private String groupsTableId; 33 | 34 | @JsonProperty 35 | @NotEmpty 36 | private String groupLogsTableId; 37 | 38 | 39 | public String getProjectId() { 40 | return projectId; 41 | } 42 | 43 | public String getInstanceId() { 44 | return instanceId; 45 | } 46 | 47 | public String getContactManifestsTableId() { 48 | return contactManifestsTableId; 49 | } 50 | 51 | public String getContactsTableId() { 52 | return contactsTableId; 53 | } 54 | 55 | public String getGroupsTableId() { 56 | return groupsTableId; 57 | } 58 | 59 | public String getGroupLogsTableId() { 60 | return groupLogsTableId; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/DatadogConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | import io.micrometer.datadog.DatadogConfig; 10 | import jakarta.validation.constraints.Min; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.NotNull; 13 | import java.time.Duration; 14 | 15 | public class DatadogConfiguration implements DatadogConfig { 16 | 17 | @JsonProperty 18 | @NotBlank 19 | private String apiKey; 20 | 21 | @JsonProperty 22 | @NotNull 23 | private Duration step = Duration.ofSeconds(10); 24 | 25 | @JsonProperty 26 | @NotBlank 27 | private String environment; 28 | 29 | @JsonProperty 30 | @Min(1) 31 | private int batchSize = 5_000; 32 | 33 | @Override 34 | public String apiKey() { 35 | return apiKey; 36 | } 37 | 38 | @Override 39 | public Duration step() { 40 | return step; 41 | } 42 | 43 | public String getEnvironment() { 44 | return environment; 45 | } 46 | 47 | @Override 48 | public int batchSize() { 49 | return batchSize; 50 | } 51 | 52 | @Override 53 | public String hostTag() { 54 | return "host"; 55 | } 56 | 57 | @Override 58 | public String get(final String key) { 59 | return null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ByteArrayAdapter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | 9 | import com.fasterxml.jackson.core.JsonGenerator; 10 | import com.fasterxml.jackson.core.JsonParser; 11 | import com.fasterxml.jackson.core.JsonProcessingException; 12 | import com.fasterxml.jackson.databind.DeserializationContext; 13 | import com.fasterxml.jackson.databind.JsonDeserializer; 14 | import com.fasterxml.jackson.databind.JsonSerializer; 15 | import com.fasterxml.jackson.databind.SerializerProvider; 16 | 17 | import org.apache.commons.codec.binary.Base64; 18 | 19 | import java.io.IOException; 20 | 21 | public class ByteArrayAdapter { 22 | 23 | public static class Serializing extends JsonSerializer { 24 | @Override 25 | public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) 26 | throws IOException, JsonProcessingException 27 | { 28 | jsonGenerator.writeString(Base64.encodeBase64String(bytes)); 29 | } 30 | } 31 | 32 | public static class Deserializing extends JsonDeserializer { 33 | @Override 34 | public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) 35 | throws IOException, JsonProcessingException 36 | { 37 | return Base64.decodeBase64(jsonParser.getValueAsString()); 38 | } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/UserAuthenticator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import io.dropwizard.auth.AuthenticationException; 9 | import io.dropwizard.auth.Authenticator; 10 | import io.dropwizard.auth.basic.BasicCredentials; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | 14 | import java.util.Optional; 15 | import java.util.UUID; 16 | 17 | public class UserAuthenticator implements Authenticator { 18 | 19 | private final Logger logger = LoggerFactory.getLogger(UserAuthenticator.class); 20 | 21 | private final ExternalServiceCredentialValidator validator; 22 | 23 | public UserAuthenticator(ExternalServiceCredentialValidator validator) { 24 | this.validator = validator; 25 | } 26 | 27 | @Override 28 | public Optional authenticate(BasicCredentials basicCredentials) throws AuthenticationException { 29 | if (validator.isValid(basicCredentials.getPassword(), basicCredentials.getUsername(), System.currentTimeMillis())) { 30 | try { 31 | UUID userId = UUID.fromString(basicCredentials.getUsername()); 32 | return Optional.of(new User(userId)); 33 | } catch (IllegalArgumentException e) { 34 | logger.warn("Successful authentication of non-UUID?", e); 35 | return Optional.empty(); 36 | } 37 | } 38 | 39 | return Optional.empty(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/s3/Base16Lower.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.s3; 7 | 8 | 9 | public class Base16Lower { 10 | 11 | private final static char[] HEX = new char[]{ 12 | '0', '1', '2', '3', '4', '5', '6', '7', 13 | '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; 14 | 15 | /** 16 | * Convert bytes to a base16 string. 17 | */ 18 | public static String encode(byte[] byteArray) { 19 | StringBuffer hexBuffer = new StringBuffer(byteArray.length * 2); 20 | for (int i = 0; i < byteArray.length; i++) 21 | for (int j = 1; j >= 0; j--) 22 | hexBuffer.append(HEX[(byteArray[i] >> (j * 4)) & 0xF]); 23 | return hexBuffer.toString(); 24 | } 25 | 26 | /** 27 | * Convert a base16 string into a byte array. 28 | */ 29 | public static byte[] decode(String s) { 30 | int len = s.length(); 31 | byte[] r = new byte[len / 2]; 32 | for (int i = 0; i < r.length; i++) { 33 | int digit1 = s.charAt(i * 2), digit2 = s.charAt(i * 2 + 1); 34 | if (digit1 >= '0' && digit1 <= '9') 35 | digit1 -= '0'; 36 | else if (digit1 >= 'A' && digit1 <= 'F') 37 | digit1 -= 'A' - 10; 38 | if (digit2 >= '0' && digit2 <= '9') 39 | digit2 -= '0'; 40 | else if (digit2 >= 'A' && digit2 <= 'F') 41 | digit2 -= 'A' - 10; 42 | 43 | r[i] = (byte) ((digit1 << 4) + digit2); 44 | } 45 | return r; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/configuration/GroupConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.configuration; 7 | 8 | import org.signal.storageservice.util.ExactlySize; 9 | import org.signal.storageservice.util.HexByteArrayAdapter; 10 | 11 | import java.time.Duration; 12 | 13 | import jakarta.validation.constraints.Positive; 14 | 15 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 16 | 17 | public record GroupConfiguration( 18 | @Positive int maxGroupSize, 19 | @Positive int maxGroupTitleLengthBytes, 20 | @Positive int maxGroupDescriptionLengthBytes, 21 | @JsonDeserialize(using = HexByteArrayAdapter.Deserializing.class) @ExactlySize(32) byte[] externalServiceSecret, 22 | Duration groupSendEndorsementExpirationTime, 23 | Duration groupSendEndorsementMinimumLifetime) { 24 | 25 | public static final Duration DEFAULT_GROUP_SEND_ENDORSEMENT_EXPIRATION_INTERVAL = Duration.ofDays(1); 26 | public static final Duration DEFAULT_GROUP_SEND_ENDORSEMENT_MINIMUM_LIFETIME = Duration.ofHours(6); 27 | 28 | public GroupConfiguration { 29 | if (groupSendEndorsementExpirationTime == null) { 30 | groupSendEndorsementExpirationTime = DEFAULT_GROUP_SEND_ENDORSEMENT_EXPIRATION_INTERVAL; 31 | } 32 | if (groupSendEndorsementMinimumLifetime == null) { 33 | groupSendEndorsementMinimumLifetime = DEFAULT_GROUP_SEND_ENDORSEMENT_MINIMUM_LIFETIME; 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/NetworkSentGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import org.signal.storageservice.util.Pair; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.io.IOException; 13 | 14 | public class NetworkSentGauge extends NetworkGauge { 15 | 16 | private final Logger logger = LoggerFactory.getLogger(NetworkSentGauge.class); 17 | 18 | private long lastTimestamp; 19 | private long lastSent; 20 | 21 | public NetworkSentGauge() { 22 | try { 23 | this.lastTimestamp = System.currentTimeMillis(); 24 | this.lastSent = getSentReceived().first(); 25 | } catch (IOException e) { 26 | logger.warn(NetworkSentGauge.class.getSimpleName(), e); 27 | } 28 | } 29 | 30 | @Override 31 | public Double getValue() { 32 | try { 33 | long timestamp = System.currentTimeMillis(); 34 | Pair sentAndReceived = getSentReceived(); 35 | double bytesTransmitted = sentAndReceived.first() - lastSent; 36 | double secondsElapsed = (timestamp - this.lastTimestamp) / 1000; 37 | double result = bytesTransmitted / secondsElapsed; 38 | 39 | this.lastSent = sentAndReceived.first(); 40 | this.lastTimestamp = timestamp; 41 | 42 | return result; 43 | } catch (IOException e) { 44 | logger.warn("NetworkSentGauge", e); 45 | return -1D; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/logging/LoggingUnhandledExceptionMapper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.logging; 7 | 8 | import io.dropwizard.jersey.errors.LoggingExceptionMapper; 9 | import jakarta.inject.Provider; 10 | import jakarta.ws.rs.core.Context; 11 | import org.glassfish.jersey.server.ContainerRequest; 12 | import org.signal.storageservice.util.UriInfoUtil; 13 | 14 | public class LoggingUnhandledExceptionMapper extends LoggingExceptionMapper { 15 | 16 | @Context 17 | private Provider request; 18 | 19 | public LoggingUnhandledExceptionMapper() { 20 | super(); 21 | } 22 | 23 | @Override 24 | protected String formatLogMessage(final long id, final Throwable exception) { 25 | String requestMethod = "unknown method"; 26 | String userAgent = "missing"; 27 | String requestPath = "/{unknown path}"; 28 | try { 29 | // request shouldn’t be `null`, but it is technically possible 30 | requestMethod = request.get().getMethod(); 31 | requestPath = UriInfoUtil.getPathTemplate(request.get().getUriInfo()); 32 | userAgent = request.get().getHeaderString("user-agent"); 33 | } catch (final Exception e) { 34 | logger.warn("Unexpected exception getting request details", e); 35 | } 36 | 37 | return String.format("%s at %s %s (%s)", 38 | super.formatLogMessage(id, exception), 39 | requestMethod, 40 | requestPath, 41 | userAgent) ; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/ExactlySize.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import static java.lang.annotation.ElementType.ANNOTATION_TYPE; 9 | import static java.lang.annotation.ElementType.CONSTRUCTOR; 10 | import static java.lang.annotation.ElementType.FIELD; 11 | import static java.lang.annotation.ElementType.METHOD; 12 | import static java.lang.annotation.ElementType.PARAMETER; 13 | import static java.lang.annotation.ElementType.TYPE_USE; 14 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 15 | 16 | import java.lang.annotation.Documented; 17 | import java.lang.annotation.Retention; 18 | import java.lang.annotation.Target; 19 | import jakarta.validation.Constraint; 20 | import jakarta.validation.Payload; 21 | 22 | @Target({ FIELD, METHOD, CONSTRUCTOR, PARAMETER, ANNOTATION_TYPE, TYPE_USE }) 23 | @Retention(RUNTIME) 24 | @Constraint(validatedBy = { 25 | ExactlySizeValidatorForString.class, 26 | ExactlySizeValidatorForArraysOfByte.class, 27 | ExactlySizeValidatorForCollection.class, 28 | }) 29 | @Documented 30 | public @interface ExactlySize { 31 | 32 | String message() default "{org.signal.storageservice.util.ExactlySize.message}"; 33 | 34 | Class[] groups() default { }; 35 | 36 | Class[] payload() default { }; 37 | 38 | int[] value(); 39 | 40 | @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE }) 41 | @Retention(RUNTIME) 42 | @Documented 43 | @interface List { 44 | ExactlySize[] value(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/NetworkReceivedGauge.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import org.signal.storageservice.util.Pair; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.io.IOException; 13 | 14 | public class NetworkReceivedGauge extends NetworkGauge { 15 | 16 | private final Logger logger = LoggerFactory.getLogger(NetworkSentGauge.class); 17 | 18 | private long lastTimestamp; 19 | private long lastReceived; 20 | 21 | public NetworkReceivedGauge() { 22 | try { 23 | this.lastTimestamp = System.currentTimeMillis(); 24 | this.lastReceived = getSentReceived().second(); 25 | } catch (IOException e) { 26 | logger.warn(NetworkReceivedGauge.class.getSimpleName(), e); 27 | } 28 | } 29 | 30 | @Override 31 | public Double getValue() { 32 | try { 33 | long timestamp = System.currentTimeMillis(); 34 | Pair sentAndReceived = getSentReceived(); 35 | double bytesReceived = sentAndReceived.second() - lastReceived; 36 | double secondsElapsed = (timestamp - this.lastTimestamp) / 1000; 37 | double result = bytesReceived / secondsElapsed; 38 | 39 | this.lastTimestamp = timestamp; 40 | this.lastReceived = sentAndReceived.second(); 41 | 42 | return result; 43 | } catch (IOException e) { 44 | logger.warn("NetworkReceivedGauge", e); 45 | return -1D; 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/controllers/ReadinessController.java: -------------------------------------------------------------------------------- 1 | package org.signal.storageservice.controllers; 2 | 3 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 4 | import com.google.cloud.bigtable.data.v2.models.Query; 5 | import com.google.cloud.bigtable.data.v2.models.TableId; 6 | import java.util.Set; 7 | import java.util.concurrent.atomic.AtomicInteger; 8 | import java.util.stream.Collectors; 9 | import jakarta.ws.rs.GET; 10 | import jakarta.ws.rs.Path; 11 | 12 | @Path("_ready") 13 | public class ReadinessController { 14 | 15 | private final BigtableDataClient client; 16 | private final Set tableIds; 17 | private final AtomicInteger clientWarmups; 18 | 19 | public ReadinessController(final BigtableDataClient client, final Set tableIds, final int clientWarmups) { 20 | this.client = client; 21 | this.tableIds = tableIds.stream() 22 | .map(TableId::of) 23 | .collect(Collectors.toSet()); 24 | this.clientWarmups = new AtomicInteger(clientWarmups); 25 | } 26 | 27 | @GET 28 | public String isReady() { 29 | 30 | if (clientWarmups.getAndDecrement() > 0) { 31 | // The first few times this is called, run some warm-up queries. 32 | // Note: unless one of these queries throws an unchecked exception, this will still invariably return a 200, 33 | // meaning the instance may be put in service before all warmups have run, depending on the load balancer 34 | // configuration. 35 | tableIds.forEach(tableId -> client.readRows(Query.create(tableId).limit(1)).stream().findAny()); 36 | } 37 | 38 | return "ready"; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/HexByteArrayAdapter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | 9 | import com.fasterxml.jackson.core.JsonGenerator; 10 | import com.fasterxml.jackson.core.JsonParseException; 11 | import com.fasterxml.jackson.core.JsonParser; 12 | import com.fasterxml.jackson.core.JsonProcessingException; 13 | import com.fasterxml.jackson.databind.DeserializationContext; 14 | import com.fasterxml.jackson.databind.JsonDeserializer; 15 | import com.fasterxml.jackson.databind.JsonSerializer; 16 | import com.fasterxml.jackson.databind.SerializerProvider; 17 | 18 | import java.io.IOException; 19 | import java.util.HexFormat; 20 | 21 | public class HexByteArrayAdapter { 22 | 23 | public static class Serializing extends JsonSerializer { 24 | @Override 25 | public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { 26 | jsonGenerator.writeString(HexFormat.of().formatHex(bytes)); 27 | } 28 | } 29 | 30 | public static class Deserializing extends JsonDeserializer { 31 | @Override 32 | public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) 33 | throws IOException, JsonProcessingException { 34 | try { 35 | return HexFormat.of().parseHex(jsonParser.getValueAsString()); 36 | } catch (IllegalArgumentException e) { 37 | throw new JsonParseException(jsonParser, "failed to decode hex", e); 38 | } 39 | } 40 | } 41 | 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/NoUnknownFieldsValidator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.providers; 7 | 8 | import com.google.protobuf.Descriptors; 9 | import com.google.protobuf.Message; 10 | 11 | import jakarta.validation.ConstraintValidator; 12 | import jakarta.validation.ConstraintValidatorContext; 13 | import java.util.Map; 14 | 15 | public class NoUnknownFieldsValidator implements ConstraintValidator { 16 | 17 | private boolean recursive; 18 | 19 | @Override 20 | public void initialize(NoUnknownFields constraintAnnotation) { 21 | recursive = constraintAnnotation.recursive(); 22 | } 23 | 24 | @Override 25 | public boolean isValid(Message value, ConstraintValidatorContext context) { 26 | if (!value.getUnknownFields().asMap().isEmpty()) return false; 27 | if (recursive) { 28 | for (Map.Entry entry : value.getAllFields().entrySet()) { 29 | if (entry.getKey().getType() == Descriptors.FieldDescriptor.Type.MESSAGE || 30 | entry.getKey().getType() == Descriptors.FieldDescriptor.Type.GROUP) { 31 | if (entry.getKey().isRepeated()) { 32 | //noinspection unchecked 33 | for (Message message : (Iterable) entry.getValue()) { 34 | if (!isValid(message, context)) return false; 35 | } 36 | } else { 37 | if (!isValid((Message) entry.getValue(), context)) return false; 38 | } 39 | } 40 | } 41 | } 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/HostSupplier.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import com.google.cloud.MetadataConfig; 9 | import java.net.InetAddress; 10 | import java.net.UnknownHostException; 11 | import java.util.Optional; 12 | import java.util.UUID; 13 | import org.apache.commons.lang3.StringUtils; 14 | 15 | /** 16 | * This class attempts to supply a meaningful host string for use in metrics and logging attribution. It prioritizes the 17 | * hostname value via {@link InetAddress#getHostName()}, but falls back to: 18 | *
    19 | *
  • an instance ID from cloud provider metadata
  • 20 | *
  • random ID if no cloud provider instance ID is available
  • 21 | *
22 | *

23 | * In the current implementation, only GCP is supported, but support for other 24 | * platforms may be added in the future. 25 | */ 26 | public class HostSupplier { 27 | 28 | private static final String FALLBACK_INSTANCE_ID = UUID.randomUUID().toString(); 29 | 30 | public static String getHost() { 31 | return getHostName() 32 | .orElse(StringUtils.defaultIfBlank(MetadataConfig.getInstanceId(), FALLBACK_INSTANCE_ID)); 33 | } 34 | 35 | private static Optional getHostName() { 36 | try { 37 | final String hostname = InetAddress.getLocalHost().getHostName(); 38 | if ("localhost".equals(hostname)) { 39 | return Optional.empty(); 40 | } 41 | 42 | return Optional.ofNullable(hostname); 43 | } catch (UnknownHostException e) { 44 | return Optional.empty(); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/InvalidProtocolBufferExceptionMapper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2019 Smoke Turner, LLC (github@smoketurner.com) 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package org.signal.storageservice.providers; 17 | 18 | import com.google.protobuf.InvalidProtocolBufferException; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import jakarta.ws.rs.core.MediaType; 23 | import jakarta.ws.rs.core.Response; 24 | import jakarta.ws.rs.ext.ExceptionMapper; 25 | import jakarta.ws.rs.ext.Provider; 26 | 27 | @Provider 28 | public class InvalidProtocolBufferExceptionMapper implements ExceptionMapper { 29 | 30 | private static final Logger LOGGER = LoggerFactory.getLogger(InvalidProtocolBufferExceptionMapper.class); 31 | 32 | @Override 33 | public Response toResponse(InvalidProtocolBufferException exception) { 34 | LOGGER.debug("Unable to process protocol buffer message", exception); 35 | return Response.status(Response.Status.BAD_REQUEST) 36 | .type(MediaType.TEXT_PLAIN_TYPE) 37 | .entity("Unable to process protocol buffer message") 38 | .build(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/ProtocolBufferValidationErrorMessageBodyWriter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.providers; 7 | 8 | import io.dropwizard.jersey.validation.ValidationErrorMessage; 9 | 10 | import jakarta.ws.rs.Produces; 11 | import jakarta.ws.rs.WebApplicationException; 12 | import jakarta.ws.rs.core.MediaType; 13 | import jakarta.ws.rs.core.MultivaluedMap; 14 | import jakarta.ws.rs.ext.MessageBodyWriter; 15 | import jakarta.ws.rs.ext.Provider; 16 | import java.io.IOException; 17 | import java.io.OutputStream; 18 | import java.lang.annotation.Annotation; 19 | import java.lang.reflect.Type; 20 | 21 | @Provider 22 | @Produces({ 23 | ProtocolBufferMediaType.APPLICATION_PROTOBUF, 24 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_TEXT, 25 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_JSON 26 | }) 27 | public class ProtocolBufferValidationErrorMessageBodyWriter implements MessageBodyWriter { 28 | @Override 29 | public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 30 | return ValidationErrorMessage.class.isAssignableFrom(type); 31 | } 32 | 33 | @Override 34 | public long getSize(ValidationErrorMessage validationErrorMessage, Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 35 | return 0; 36 | } 37 | 38 | @Override 39 | public void writeTo(ValidationErrorMessage validationErrorMessage, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException { 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/ProtocolBufferMediaType.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2019 Smoke Turner, LLC (github@smoketurner.com) 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package org.signal.storageservice.providers; 17 | 18 | import jakarta.ws.rs.core.MediaType; 19 | 20 | public class ProtocolBufferMediaType extends MediaType { 21 | 22 | /** "application/x-protobuf" */ 23 | public static final String APPLICATION_PROTOBUF = "application/x-protobuf"; 24 | /** "application/x-protobuf" */ 25 | public static final MediaType APPLICATION_PROTOBUF_TYPE = 26 | new MediaType("application", "x-protobuf"); 27 | 28 | /** "application/x-protobuf-text-format" */ 29 | public static final String APPLICATION_PROTOBUF_TEXT = "application/x-protobuf-text-format"; 30 | /** "application/x-protobuf-text-format" */ 31 | public static final MediaType APPLICATION_PROTOBUF_TEXT_TYPE = 32 | new MediaType("application", "x-protobuf-text-format"); 33 | 34 | /** "application/x-protobuf-json-format" */ 35 | public static final String APPLICATION_PROTOBUF_JSON = "application/x-protobuf-json-format"; 36 | /** "application/x-protobuf-json-format" */ 37 | public static final MediaType APPLICATION_PROTOBUF_JSON_TYPE = 38 | new MediaType("application", "x-protobuf-json-format"); 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/filters/TimestampResponseFilter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.filters; 7 | 8 | import java.io.IOException; 9 | import java.time.Clock; 10 | import jakarta.servlet.Filter; 11 | import jakarta.servlet.FilterChain; 12 | import jakarta.servlet.ServletException; 13 | import jakarta.servlet.ServletRequest; 14 | import jakarta.servlet.ServletResponse; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | import jakarta.ws.rs.container.ContainerRequestContext; 17 | import jakarta.ws.rs.container.ContainerResponseContext; 18 | import jakarta.ws.rs.container.ContainerResponseFilter; 19 | import org.signal.storageservice.util.HeaderUtils; 20 | 21 | /** 22 | * Injects a timestamp header into all outbound responses. 23 | */ 24 | public class TimestampResponseFilter implements Filter, ContainerResponseFilter { 25 | 26 | private final Clock clock; 27 | 28 | public TimestampResponseFilter(final Clock clock) { 29 | this.clock = clock; 30 | } 31 | 32 | @Override 33 | public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) 34 | throws ServletException, IOException { 35 | 36 | if (response instanceof HttpServletResponse httpServletResponse) { 37 | httpServletResponse.setHeader(HeaderUtils.TIMESTAMP_HEADER, String.valueOf(clock.millis())); 38 | } 39 | 40 | chain.doFilter(request, response); 41 | } 42 | 43 | @Override 44 | public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { 45 | // not using add() - it's ok to overwrite any existing header, and we don't want a multi-value 46 | responseContext.getHeaders().putSingle(HeaderUtils.TIMESTAMP_HEADER, String.valueOf(clock.millis())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/s3/PolicySigner.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.s3; 7 | 8 | import javax.crypto.Mac; 9 | import javax.crypto.spec.SecretKeySpec; 10 | import java.nio.charset.StandardCharsets; 11 | import java.security.InvalidKeyException; 12 | import java.security.NoSuchAlgorithmException; 13 | import java.time.ZonedDateTime; 14 | import java.time.format.DateTimeFormatter; 15 | 16 | public class PolicySigner { 17 | 18 | private final String awsAccessSecret; 19 | private final String region; 20 | 21 | public PolicySigner(String awsAccessSecret, String region) { 22 | this.awsAccessSecret = awsAccessSecret; 23 | this.region = region; 24 | } 25 | 26 | public String getSignature(ZonedDateTime now, String policy) { 27 | try { 28 | Mac mac = Mac.getInstance("HmacSHA256"); 29 | 30 | mac.init(new SecretKeySpec(("AWS4" + awsAccessSecret).getBytes(StandardCharsets.UTF_8), "HmacSHA256")); 31 | byte[] dateKey = mac.doFinal(now.format(DateTimeFormatter.ofPattern("yyyyMMdd")).getBytes(StandardCharsets.UTF_8)); 32 | 33 | mac.init(new SecretKeySpec(dateKey, "HmacSHA256")); 34 | byte[] dateRegionKey = mac.doFinal(region.getBytes(StandardCharsets.UTF_8)); 35 | 36 | mac.init(new SecretKeySpec(dateRegionKey, "HmacSHA256")); 37 | byte[] dateRegionServiceKey = mac.doFinal("s3".getBytes(StandardCharsets.UTF_8)); 38 | 39 | mac.init(new SecretKeySpec(dateRegionServiceKey, "HmacSHA256")); 40 | byte[] signingKey = mac.doFinal("aws4_request".getBytes(StandardCharsets.UTF_8)); 41 | 42 | mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); 43 | 44 | return Base16Lower.encode(mac.doFinal(policy.getBytes(StandardCharsets.UTF_8))); 45 | } catch (NoSuchAlgorithmException | InvalidKeyException e) { 46 | throw new AssertionError(e); 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/UserAgentTagUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.vdurmont.semver4j.Semver; 9 | import io.micrometer.core.instrument.Tag; 10 | import org.signal.storageservice.util.ua.ClientPlatform; 11 | import org.signal.storageservice.util.ua.UnrecognizedUserAgentException; 12 | import org.signal.storageservice.util.ua.UserAgent; 13 | import org.signal.storageservice.util.ua.UserAgentUtil; 14 | import java.util.Collections; 15 | import java.util.Map; 16 | import java.util.Optional; 17 | import java.util.Set; 18 | 19 | /** 20 | * Utility class for extracting platform/version metrics tags from User-Agent strings. 21 | */ 22 | public class UserAgentTagUtil { 23 | 24 | public static final String PLATFORM_TAG = "platform"; 25 | public static final String VERSION_TAG = "clientVersion"; 26 | 27 | private UserAgentTagUtil() { 28 | } 29 | 30 | public static Tag getPlatformTag(final String userAgentString) { 31 | String platform; 32 | 33 | try { 34 | platform = UserAgentUtil.parseUserAgentString(userAgentString).platform().name().toLowerCase(); 35 | } catch (final UnrecognizedUserAgentException e) { 36 | platform = "unrecognized"; 37 | } 38 | 39 | return Tag.of(PLATFORM_TAG, platform); 40 | } 41 | 42 | public static Optional getClientVersionTag(final String userAgentString, 43 | final Map> recognizedVersionsByPlatform) { 44 | 45 | try { 46 | final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); 47 | 48 | final Set recognizedVersions = 49 | recognizedVersionsByPlatform.getOrDefault(userAgent.platform(), Collections.emptySet()); 50 | 51 | if (recognizedVersions.contains(userAgent.version())) { 52 | return Optional.of(Tag.of(VERSION_TAG, userAgent.version().toString())); 53 | } 54 | } catch (final UnrecognizedUserAgentException ignored) { 55 | } 56 | 57 | return Optional.empty(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/auth/GroupUserTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | 10 | import java.util.Random; 11 | import java.util.stream.Stream; 12 | 13 | import org.junit.jupiter.params.ParameterizedTest; 14 | import org.junit.jupiter.params.provider.Arguments; 15 | import org.junit.jupiter.params.provider.MethodSource; 16 | 17 | import com.google.protobuf.ByteString; 18 | 19 | class GroupUserTest { 20 | 21 | @ParameterizedTest 22 | @MethodSource 23 | void isMember(final ByteString userAci, 24 | final ByteString userPni, 25 | final ByteString userPublicKey, 26 | final ByteString memberUuid, 27 | final ByteString groupPublicKey, 28 | final boolean expectIsMember) { 29 | 30 | final GroupUser groupUser = new GroupUser(userAci, userPni, userPublicKey, generateRandomByteString()); 31 | 32 | assertEquals(expectIsMember, groupUser.isMember(memberUuid, groupPublicKey)); 33 | } 34 | 35 | private static Stream isMember() { 36 | final ByteString memberUuid = generateRandomByteString(); 37 | final ByteString groupPublicKey = generateRandomByteString(); 38 | 39 | return Stream.of( 40 | Arguments.of(memberUuid, generateRandomByteString(), groupPublicKey, memberUuid, groupPublicKey, true), 41 | Arguments.of(memberUuid, null, groupPublicKey, memberUuid, groupPublicKey, true), 42 | Arguments.of(generateRandomByteString(), memberUuid, groupPublicKey, memberUuid, groupPublicKey, true), 43 | Arguments.of(generateRandomByteString(), null, groupPublicKey, memberUuid, groupPublicKey, false), 44 | Arguments.of(generateRandomByteString(), generateRandomByteString(), groupPublicKey, memberUuid, groupPublicKey, 45 | false), 46 | Arguments.of(memberUuid, null, generateRandomByteString(), memberUuid, groupPublicKey, false)); 47 | } 48 | 49 | private static ByteString generateRandomByteString() { 50 | final Random random = new Random(); 51 | final byte[] bytes = new byte[16]; 52 | 53 | random.nextBytes(bytes); 54 | 55 | return ByteString.copyFrom(bytes); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/ExternalGroupCredentialGenerator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import com.google.protobuf.ByteString; 9 | import org.apache.commons.codec.binary.Hex; 10 | import org.signal.storageservice.util.Util; 11 | 12 | import javax.crypto.Mac; 13 | import javax.crypto.spec.SecretKeySpec; 14 | import java.nio.charset.StandardCharsets; 15 | import java.security.InvalidKeyException; 16 | import java.security.MessageDigest; 17 | import java.security.NoSuchAlgorithmException; 18 | import java.time.Clock; 19 | 20 | public class ExternalGroupCredentialGenerator { 21 | 22 | private final byte[] key; 23 | private final Clock clock; 24 | 25 | public ExternalGroupCredentialGenerator(byte[] key, Clock clock) { 26 | this.key = key; 27 | this.clock = clock; 28 | } 29 | 30 | public String generateFor(ByteString uuidCiphertext, ByteString groupId, boolean isAllowedToInitiateGroupCall) { 31 | final MessageDigest digest = getDigestInstance(); 32 | final long currentTimeSeconds = clock.millis() / 1000; 33 | String encodedData = 34 | "2:" 35 | + Hex.encodeHexString(digest.digest(uuidCiphertext.toByteArray())) + ":" 36 | + Hex.encodeHexString(groupId.toByteArray()) + ":" 37 | + currentTimeSeconds + ":" 38 | + (isAllowedToInitiateGroupCall ? "1" : "0"); 39 | String truncatedHmac = Hex.encodeHexString( 40 | Util.truncate(getHmac(key, encodedData.getBytes(StandardCharsets.UTF_8)), 10)); 41 | 42 | return encodedData + ":" + truncatedHmac; 43 | } 44 | 45 | private Mac getMacInstance() { 46 | try { 47 | return Mac.getInstance("HmacSHA256"); 48 | } catch (NoSuchAlgorithmException e) { 49 | throw new AssertionError(e); 50 | } 51 | } 52 | 53 | private MessageDigest getDigestInstance() { 54 | try { 55 | return MessageDigest.getInstance("SHA-256"); 56 | } catch (NoSuchAlgorithmException e) { 57 | throw new AssertionError(e); 58 | } 59 | } 60 | 61 | private byte[] getHmac(byte[] key, byte[] input) { 62 | try { 63 | final Mac mac = getMacInstance(); 64 | mac.init(new SecretKeySpec(key, "HmacSHA256")); 65 | return mac.doFinal(input); 66 | } catch (InvalidKeyException e) { 67 | throw new AssertionError(e); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/controllers/GroupsControllerPhoneNumberPrivacyTest.java: -------------------------------------------------------------------------------- 1 | package org.signal.storageservice.controllers; 2 | 3 | import jakarta.ws.rs.client.Entity; 4 | import jakarta.ws.rs.core.Response; 5 | import com.google.protobuf.ByteString; 6 | import org.junit.jupiter.api.Test; 7 | import org.signal.storageservice.providers.ProtocolBufferMediaType; 8 | import org.signal.storageservice.storage.protos.groups.Group; 9 | import org.signal.storageservice.storage.protos.groups.GroupChange; 10 | import org.signal.storageservice.storage.protos.groups.Member; 11 | import org.signal.storageservice.storage.protos.groups.MemberPendingProfileKey; 12 | import org.signal.storageservice.util.AuthHelper; 13 | 14 | class GroupsControllerPhoneNumberPrivacyTest extends BaseGroupsControllerTest { 15 | 16 | @Test 17 | void testRejectInvitationFromPni() throws Exception { 18 | Group.Builder groupBuilder = Group.newBuilder(this.group) 19 | .addMembersPendingProfileKey(MemberPendingProfileKey.newBuilder() 20 | .setMember(Member.newBuilder() 21 | .setUserId(validUserThreePniId) 22 | .setRole(Member.Role.DEFAULT) 23 | .setJoinedAtVersion(0) 24 | .build()) 25 | .setAddedByUserId(validUserId) 26 | .setTimestamp(clock.millis()) 27 | .build()); 28 | 29 | setupGroupsManagerBehaviors(groupBuilder.build()); 30 | 31 | GroupChange.Actions.Builder actionsBuilder = GroupChange.Actions.newBuilder() 32 | .setVersion(1) 33 | .addDeleteMembersPendingProfileKey(GroupChange.Actions.DeleteMemberPendingProfileKeyAction.newBuilder() 34 | .setDeletedUserId(validUserThreePniId) 35 | .build()); 36 | 37 | groupBuilder.clearMembersPendingProfileKey().setVersion(1); 38 | 39 | try (Response response = resources.getJerseyTest() 40 | .target("/v1/groups/") 41 | .request(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 42 | .header("Authorization", AuthHelper.getAuthHeader(groupSecretParams, AuthHelper.VALID_USER_THREE_AUTH_CREDENTIAL)) 43 | .method("PATCH", Entity.entity(actionsBuilder.build().toByteArray(), ProtocolBufferMediaType.APPLICATION_PROTOBUF))) { 44 | 45 | actionsBuilder.setGroupId(ByteString.copyFrom(groupPublicParams.getGroupIdentifier().serialize())); 46 | verifyGroupModification(groupBuilder, actionsBuilder, 0, response, validUserThreePniId); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/filters/TimestampResponseFilterTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.filters; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | import static org.mockito.ArgumentMatchers.eq; 11 | import static org.mockito.Mockito.mock; 12 | import static org.mockito.Mockito.verify; 13 | import static org.mockito.Mockito.when; 14 | 15 | import java.time.Clock; 16 | import java.time.Instant; 17 | import java.time.ZoneId; 18 | import jakarta.servlet.FilterChain; 19 | import jakarta.servlet.http.HttpServletRequest; 20 | import jakarta.servlet.http.HttpServletResponse; 21 | import jakarta.ws.rs.container.ContainerRequestContext; 22 | import jakarta.ws.rs.container.ContainerResponseContext; 23 | import jakarta.ws.rs.core.MultivaluedMap; 24 | import org.junit.jupiter.api.Test; 25 | import org.signal.storageservice.util.HeaderUtils; 26 | 27 | class TimestampResponseFilterTest { 28 | 29 | private static final long EPOCH_MILLIS = 1738182156000L; 30 | 31 | private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(EPOCH_MILLIS), ZoneId.systemDefault()); 32 | 33 | @Test 34 | void testJerseyFilter() { 35 | final ContainerRequestContext requestContext = mock(ContainerRequestContext.class); 36 | final ContainerResponseContext responseContext = mock(ContainerResponseContext.class); 37 | final MultivaluedMap headers = org.glassfish.jersey.message.internal.HeaderUtils.createOutbound(); 38 | when(responseContext.getHeaders()).thenReturn(headers); 39 | 40 | new TimestampResponseFilter(CLOCK).filter(requestContext, responseContext); 41 | 42 | assertTrue(headers.containsKey(HeaderUtils.TIMESTAMP_HEADER)); 43 | assertEquals(1, headers.get(HeaderUtils.TIMESTAMP_HEADER).size()); 44 | assertEquals(String.valueOf(EPOCH_MILLIS), headers.get(HeaderUtils.TIMESTAMP_HEADER).get(0)); 45 | } 46 | 47 | @Test 48 | void testServletFilter() throws Exception { 49 | final HttpServletRequest request = mock(HttpServletRequest.class); 50 | final HttpServletResponse response = mock(HttpServletResponse.class); 51 | 52 | new TimestampResponseFilter(CLOCK).doFilter(request, response, mock(FilterChain.class)); 53 | 54 | verify(response).setHeader(eq(HeaderUtils.TIMESTAMP_HEADER), eq(String.valueOf(EPOCH_MILLIS))); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/util/TestClock.java: -------------------------------------------------------------------------------- 1 | package org.signal.storageservice.util; 2 | 3 | import java.time.Clock; 4 | import java.time.Instant; 5 | import java.time.ZoneId; 6 | import java.util.Optional; 7 | 8 | /** 9 | * Clock class specialized for testing. 10 | *

11 | * This clock can be pinned to a particular instant or can provide the "normal" time. 12 | *

13 | * Unlike normal clocks it can be dynamically pinned and unpinned to help with testing. 14 | * It should not be used in production. 15 | */ 16 | public class TestClock extends Clock { 17 | 18 | private volatile Optional pinnedInstant; 19 | private final ZoneId zoneId; 20 | 21 | private TestClock(Optional maybePinned, ZoneId id) { 22 | this.pinnedInstant = maybePinned; 23 | this.zoneId = id; 24 | } 25 | 26 | /** 27 | * Instantiate a test clock that returns the "real" time. 28 | *

29 | * The clock can later be pinned to an instant if desired. 30 | * 31 | * @return unpinned test clock. 32 | */ 33 | public static TestClock now() { 34 | return new TestClock(Optional.empty(), ZoneId.of("UTC")); 35 | } 36 | 37 | /** 38 | * Instantiate a test clock pinned to a particular instant. 39 | *

40 | * The clock can later be pinned to a different instant or unpinned if desired. 41 | *

42 | * Unlike the fixed constructor no time zone is required (it defaults to UTC). 43 | * 44 | * @param instant the instant to pin the clock to. 45 | * @return test clock pinned to the given instant. 46 | */ 47 | public static TestClock pinned(Instant instant) { 48 | return new TestClock(Optional.of(instant), ZoneId.of("UTC")); 49 | } 50 | 51 | /** 52 | * Pin this test clock to the given instance. 53 | *

54 | * This modifies the existing clock in-place. 55 | * 56 | * @param instant the instant to pin the clock to. 57 | */ 58 | public void pin(Instant instant) { 59 | this.pinnedInstant = Optional.of(instant); 60 | } 61 | 62 | /** 63 | * Unpin this test clock so it will being returning the "real" time. 64 | *

65 | * This modifies the existing clock in-place. 66 | */ 67 | public void unpin() { 68 | this.pinnedInstant = Optional.empty(); 69 | } 70 | 71 | 72 | @Override 73 | public TestClock withZone(ZoneId id) { 74 | return new TestClock(pinnedInstant, id); 75 | } 76 | 77 | @Override 78 | public ZoneId getZone() { 79 | return zoneId; 80 | } 81 | 82 | @Override 83 | public Instant instant() { 84 | return pinnedInstant.orElseGet(Instant::now); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/s3/PostPolicyGenerator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.s3; 7 | 8 | import org.apache.commons.codec.binary.Base64; 9 | import org.signal.storageservice.util.Pair; 10 | 11 | import java.nio.charset.StandardCharsets; 12 | import java.time.ZonedDateTime; 13 | import java.time.format.DateTimeFormatter; 14 | 15 | public class PostPolicyGenerator { 16 | 17 | public static final DateTimeFormatter AWS_DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmssX"); 18 | private static final DateTimeFormatter CREDENTIAL_DATE = DateTimeFormatter.ofPattern("yyyyMMdd" ); 19 | 20 | private final String region; 21 | private final String bucket; 22 | private final String awsAccessId; 23 | 24 | public PostPolicyGenerator(String region, String bucket, String awsAccessId) { 25 | this.region = region; 26 | this.bucket = bucket; 27 | this.awsAccessId = awsAccessId; 28 | } 29 | 30 | public Pair createFor(ZonedDateTime now, String object, int maxSizeInBytes) { 31 | String expiration = now.plusMinutes(30).format(DateTimeFormatter.ISO_INSTANT); 32 | String credentialDate = now.format(CREDENTIAL_DATE); 33 | String requestDate = now.format(AWS_DATE_TIME ); 34 | String credential = String.format("%s/%s/%s/s3/aws4_request", awsAccessId, credentialDate, region); 35 | 36 | String policy = String.format("{ \"expiration\": \"%s\",\n" + 37 | " \"conditions\": [\n" + 38 | " {\"bucket\": \"%s\"},\n" + 39 | " {\"key\": \"%s\"},\n" + 40 | " {\"acl\": \"private\"},\n" + 41 | " [\"starts-with\", \"$Content-Type\", \"\"],\n" + 42 | " [\"content-length-range\", 1, " + maxSizeInBytes + "],\n" + 43 | "\n" + 44 | " {\"x-amz-credential\": \"%s\"},\n" + 45 | " {\"x-amz-algorithm\": \"AWS4-HMAC-SHA256\"},\n" + 46 | " {\"x-amz-date\": \"%s\" }\n" + 47 | " ]\n" + 48 | "}", expiration, bucket, object, credential, requestDate); 49 | 50 | return new Pair<>(credential, Base64.encodeBase64String(policy.getBytes(StandardCharsets.UTF_8))); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/GroupUserAuthenticator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import static com.codahale.metrics.MetricRegistry.name; 9 | 10 | import com.google.protobuf.ByteString; 11 | import io.dropwizard.auth.Authenticator; 12 | import io.dropwizard.auth.basic.BasicCredentials; 13 | import io.micrometer.core.instrument.Metrics; 14 | import java.util.Optional; 15 | import org.apache.commons.codec.DecoderException; 16 | import org.apache.commons.codec.binary.Hex; 17 | import org.signal.libsignal.zkgroup.InvalidInputException; 18 | import org.signal.libsignal.zkgroup.VerificationFailedException; 19 | import org.signal.libsignal.zkgroup.auth.AuthCredentialPresentation; 20 | import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; 21 | import org.signal.libsignal.zkgroup.groups.GroupPublicParams; 22 | 23 | public class GroupUserAuthenticator implements Authenticator { 24 | 25 | private static final String CREDENTIALS_VERSION_COUNTER_NAME = name(GroupUserAuthenticator.class, 26 | "credentialsVersion"); 27 | 28 | private final ServerZkAuthOperations serverZkAuthOperations; 29 | 30 | public GroupUserAuthenticator(ServerZkAuthOperations serverZkAuthOperations) { 31 | this.serverZkAuthOperations = serverZkAuthOperations; 32 | } 33 | 34 | @Override 35 | public Optional authenticate(BasicCredentials basicCredentials) { 36 | try { 37 | String encodedGroupPublicKey = basicCredentials.getUsername(); 38 | String encodedPresentation = basicCredentials.getPassword(); 39 | 40 | GroupPublicParams groupPublicKey = new GroupPublicParams(Hex.decodeHex(encodedGroupPublicKey)); 41 | AuthCredentialPresentation presentation = new AuthCredentialPresentation(Hex.decodeHex(encodedPresentation)); 42 | 43 | Metrics.counter(CREDENTIALS_VERSION_COUNTER_NAME, "credentialsVersion", presentation.getVersion().toString()) 44 | .increment(); 45 | 46 | serverZkAuthOperations.verifyAuthCredentialPresentation(groupPublicKey, presentation); 47 | 48 | return Optional.of(new GroupUser(ByteString.copyFrom(presentation.getUuidCiphertext().serialize()), 49 | presentation.getPniCiphertext() != null ? ByteString.copyFrom(presentation.getPniCiphertext().serialize()) : null, 50 | ByteString.copyFrom(groupPublicKey.serialize()), 51 | ByteString.copyFrom(groupPublicKey.getGroupIdentifier().serialize()))); 52 | 53 | } catch (DecoderException | VerificationFailedException | InvalidInputException e) { 54 | return Optional.empty(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/StorageServiceConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice; 7 | 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | import com.vdurmont.semver4j.Semver; 10 | import io.dropwizard.core.Configuration; 11 | import org.signal.storageservice.configuration.AuthenticationConfiguration; 12 | import org.signal.storageservice.configuration.BigTableConfiguration; 13 | import org.signal.storageservice.configuration.CdnConfiguration; 14 | import org.signal.storageservice.configuration.DatadogConfiguration; 15 | import org.signal.storageservice.configuration.GroupConfiguration; 16 | import org.signal.storageservice.configuration.WarmupConfiguration; 17 | import org.signal.storageservice.configuration.ZkConfiguration; 18 | 19 | import jakarta.validation.Valid; 20 | import jakarta.validation.constraints.NotNull; 21 | import org.signal.storageservice.util.ua.ClientPlatform; 22 | import java.util.Collections; 23 | import java.util.Map; 24 | import java.util.Set; 25 | 26 | public class StorageServiceConfiguration extends Configuration { 27 | 28 | @JsonProperty 29 | @Valid 30 | @NotNull 31 | private BigTableConfiguration bigtable; 32 | 33 | @JsonProperty 34 | @Valid 35 | @NotNull 36 | private AuthenticationConfiguration authentication; 37 | 38 | @JsonProperty 39 | @Valid 40 | @NotNull 41 | private ZkConfiguration zkConfig; 42 | 43 | @JsonProperty 44 | @Valid 45 | @NotNull 46 | private CdnConfiguration cdn; 47 | 48 | @JsonProperty 49 | @Valid 50 | @NotNull 51 | private GroupConfiguration group; 52 | 53 | @JsonProperty 54 | @Valid 55 | @NotNull 56 | private DatadogConfiguration datadog; 57 | 58 | @JsonProperty 59 | @Valid 60 | @NotNull 61 | private WarmupConfiguration warmup = new WarmupConfiguration(5); 62 | 63 | @JsonProperty 64 | @NotNull 65 | private Map> recognizedClientVersions = Collections.emptyMap(); 66 | 67 | public BigTableConfiguration getBigTableConfiguration() { 68 | return bigtable; 69 | } 70 | 71 | public AuthenticationConfiguration getAuthenticationConfiguration() { 72 | return authentication; 73 | } 74 | 75 | public ZkConfiguration getZkConfiguration() { 76 | return zkConfig; 77 | } 78 | 79 | public CdnConfiguration getCdnConfiguration() { 80 | return cdn; 81 | } 82 | 83 | public GroupConfiguration getGroupConfiguration() { 84 | return group; 85 | } 86 | 87 | public DatadogConfiguration getDatadogConfiguration() { 88 | return datadog; 89 | } 90 | 91 | public WarmupConfiguration getWarmUpConfiguration() { 92 | return warmup; 93 | } 94 | 95 | public Map> getRecognizedClientVersions() { 96 | return recognizedClientVersions; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/GroupUser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import com.google.common.annotations.VisibleForTesting; 9 | import com.google.protobuf.ByteString; 10 | import org.signal.storageservice.storage.protos.groups.Member; 11 | import org.signal.libsignal.zkgroup.InvalidInputException; 12 | import org.signal.libsignal.zkgroup.groups.GroupPublicParams; 13 | 14 | import javax.annotation.Nullable; 15 | import javax.security.auth.Subject; 16 | import java.security.MessageDigest; 17 | import java.security.Principal; 18 | import java.util.Optional; 19 | 20 | public class GroupUser implements Principal { 21 | 22 | private final ByteString aciCiphertext; 23 | private final ByteString groupPublicKey; 24 | private final ByteString groupId; 25 | 26 | @Nullable 27 | private final ByteString pniCiphertext; 28 | 29 | public GroupUser(ByteString aciCiphertext, @Nullable ByteString pniCiphertext, ByteString groupPublicKey, ByteString groupId) { 30 | this.aciCiphertext = aciCiphertext; 31 | this.pniCiphertext = pniCiphertext; 32 | this.groupPublicKey = groupPublicKey; 33 | this.groupId = groupId; 34 | } 35 | 36 | public boolean isMember(Member member, ByteString groupPublicKey) { 37 | return isMember(member.getUserId(), groupPublicKey); 38 | } 39 | 40 | public boolean aciMatches(ByteString uuid) { 41 | return MessageDigest.isEqual(this.aciCiphertext.toByteArray(), uuid.toByteArray()); 42 | } 43 | 44 | public boolean isMember(ByteString uuid, ByteString groupPublicKey) { 45 | final boolean publicKeyMatches = MessageDigest.isEqual(this.groupPublicKey.toByteArray(), groupPublicKey.toByteArray()); 46 | final boolean aciMatches = MessageDigest.isEqual(this.aciCiphertext.toByteArray(), uuid.toByteArray()); 47 | final boolean pniMatches = 48 | pniCiphertext != null && MessageDigest.isEqual(this.pniCiphertext.toByteArray(), uuid.toByteArray()); 49 | 50 | return publicKeyMatches && (aciMatches || pniMatches); 51 | } 52 | 53 | public GroupPublicParams getGroupPublicKey() { 54 | try { 55 | return new GroupPublicParams(groupPublicKey.toByteArray()); 56 | } catch (InvalidInputException e) { 57 | throw new AssertionError(e); 58 | } 59 | } 60 | 61 | public ByteString getGroupId() { 62 | return groupId; 63 | } 64 | 65 | @VisibleForTesting 66 | ByteString getAciCiphertext() { 67 | return aciCiphertext; 68 | } 69 | 70 | public Optional getPniCiphertext() { 71 | return Optional.ofNullable(pniCiphertext); 72 | } 73 | 74 | // Principal implementation 75 | 76 | @Override 77 | public String getName() { 78 | return null; 79 | } 80 | 81 | @Override 82 | public boolean implies(Subject subject) { 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/auth/ExternalServiceCredentialValidator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import org.apache.commons.codec.DecoderException; 9 | import org.apache.commons.codec.binary.Hex; 10 | import org.signal.storageservice.util.Util; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | 14 | import javax.crypto.Mac; 15 | import javax.crypto.spec.SecretKeySpec; 16 | import java.security.InvalidKeyException; 17 | import java.security.MessageDigest; 18 | import java.security.NoSuchAlgorithmException; 19 | import java.util.concurrent.TimeUnit; 20 | 21 | public class ExternalServiceCredentialValidator { 22 | 23 | private final Logger logger = LoggerFactory.getLogger(ExternalServiceCredentialValidator.class); 24 | 25 | private final byte[] key; 26 | 27 | public ExternalServiceCredentialValidator(byte[] key) { 28 | this.key = key; 29 | } 30 | 31 | public boolean isValid(String token, String number, long currentTimeMillis) { 32 | String[] parts = token.split(":"); 33 | Mac mac = getMacInstance(); 34 | 35 | if (parts.length != 3) { 36 | return false; 37 | } 38 | 39 | if (!number.equals(parts[0])) { 40 | return false; 41 | } 42 | 43 | if (!isValidTime(parts[1], currentTimeMillis)) { 44 | return false; 45 | } 46 | 47 | return isValidSignature(parts[0] + ":" + parts[1], parts[2], mac); 48 | } 49 | 50 | private boolean isValidTime(String timeString, long currentTimeMillis) { 51 | try { 52 | long tokenTime = Long.parseLong(timeString); 53 | long ourTime = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis); 54 | 55 | return TimeUnit.SECONDS.toHours(Math.abs(ourTime - tokenTime)) < 24; 56 | } catch (NumberFormatException e) { 57 | logger.warn("Number Format", e); 58 | return false; 59 | } 60 | } 61 | 62 | private boolean isValidSignature(String prefix, String suffix, Mac mac) { 63 | try { 64 | byte[] ourSuffix = Util.truncate(getHmac(key, prefix.getBytes(), mac), 10); 65 | byte[] theirSuffix = Hex.decodeHex(suffix.toCharArray()); 66 | 67 | return MessageDigest.isEqual(ourSuffix, theirSuffix); 68 | } catch (DecoderException e) { 69 | logger.warn("DirectoryCredentials", e); 70 | return false; 71 | } 72 | } 73 | 74 | private Mac getMacInstance() { 75 | try { 76 | return Mac.getInstance("HmacSHA256"); 77 | } catch (NoSuchAlgorithmException e) { 78 | throw new AssertionError(e); 79 | } 80 | } 81 | 82 | private byte[] getHmac(byte[] key, byte[] input, Mac mac) { 83 | try { 84 | mac.init(new SecretKeySpec(key, "HmacSHA256")); 85 | return mac.doFinal(input); 86 | } catch (InvalidKeyException e) { 87 | throw new AssertionError(e); 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/auth/ExternalGroupCredentialGeneratorTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 10 | import static org.junit.jupiter.params.provider.Arguments.arguments; 11 | import static org.mockito.Mockito.mock; 12 | import static org.mockito.Mockito.when; 13 | 14 | import com.google.protobuf.ByteString; 15 | import java.nio.charset.StandardCharsets; 16 | import java.security.InvalidKeyException; 17 | import java.security.MessageDigest; 18 | import java.security.NoSuchAlgorithmException; 19 | import java.security.SecureRandom; 20 | import java.time.Clock; 21 | import java.util.stream.Stream; 22 | import javax.crypto.Mac; 23 | import javax.crypto.spec.SecretKeySpec; 24 | import org.apache.commons.codec.DecoderException; 25 | import org.apache.commons.codec.binary.Hex; 26 | import org.junit.jupiter.params.ParameterizedTest; 27 | import org.junit.jupiter.params.provider.Arguments; 28 | import org.junit.jupiter.params.provider.MethodSource; 29 | import org.signal.storageservice.util.Util; 30 | 31 | public class ExternalGroupCredentialGeneratorTest { 32 | 33 | @SuppressWarnings("unused") 34 | static Stream testArgumentsProvider() { 35 | return Stream.of( 36 | arguments(true, "1"), 37 | arguments(false, "0") 38 | ); 39 | } 40 | 41 | @ParameterizedTest 42 | @MethodSource("testArgumentsProvider") 43 | public void testGenerateValidCredentials(boolean isAllowedToCreateGroupCalls, String part4) 44 | throws DecoderException, NoSuchAlgorithmException, InvalidKeyException { 45 | Clock clock = mock(Clock.class); 46 | final long timeInMillis = new SecureRandom().nextLong() & Long.MAX_VALUE; 47 | when(clock.millis()).thenReturn(timeInMillis); 48 | 49 | byte[] key = Util.generateSecretBytes(32); 50 | ExternalGroupCredentialGenerator generator = new ExternalGroupCredentialGenerator(key, clock); 51 | ByteString uuid = ByteString.copyFrom(Util.generateSecretBytes(16)); 52 | ByteString groupId = ByteString.copyFrom(Util.generateSecretBytes(16)); 53 | 54 | String token = generator.generateFor(uuid, groupId, isAllowedToCreateGroupCalls); 55 | 56 | String[] parts = token.split(":"); 57 | assertThat(parts.length).isEqualTo(6); 58 | 59 | assertThat(parts[0]).isEqualTo("2"); 60 | assertArrayEquals(Hex.decodeHex(parts[1]), MessageDigest.getInstance("SHA-256").digest(uuid.toByteArray())); 61 | assertArrayEquals(Hex.decodeHex(parts[2]), groupId.toByteArray()); 62 | assertThat(Long.parseLong(parts[3])).isEqualTo(timeInMillis / 1000); 63 | assertThat(parts[4]).isEqualTo(part4); 64 | 65 | byte[] theirMac = Hex.decodeHex(parts[5]); 66 | Mac hmac = Mac.getInstance("HmacSHA256"); 67 | hmac.init(new SecretKeySpec(key, "HmacSHA256")); 68 | byte[] ourMac = Util.truncate(hmac.doFinal( 69 | ("2:" + parts[1] + ":" + parts[2] + ":" + parts[3] + ":" + part4).getBytes(StandardCharsets.UTF_8)), 10); 70 | assertArrayEquals(ourMac, theirMac); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/SignalDatadogReporterFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * This is derived from Coursera's dropwizard datadog reporter. 3 | * https://github.com/coursera/metrics-datadog 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.codahale.metrics.MetricRegistry; 9 | import com.codahale.metrics.ScheduledReporter; 10 | import com.fasterxml.jackson.annotation.JsonProperty; 11 | import com.fasterxml.jackson.annotation.JsonTypeName; 12 | import io.dropwizard.metrics.common.BaseReporterFactory; 13 | import java.util.ArrayList; 14 | import java.util.EnumSet; 15 | import java.util.List; 16 | import jakarta.validation.Valid; 17 | import jakarta.validation.constraints.NotNull; 18 | import org.coursera.metrics.datadog.DatadogReporter; 19 | import org.coursera.metrics.datadog.DatadogReporter.Expansion; 20 | import org.coursera.metrics.datadog.DefaultMetricNameFormatterFactory; 21 | import org.coursera.metrics.datadog.DynamicTagsCallbackFactory; 22 | import org.coursera.metrics.datadog.MetricNameFormatterFactory; 23 | import org.coursera.metrics.datadog.transport.AbstractTransportFactory; 24 | import org.signal.storageservice.StorageServiceVersion; 25 | import org.signal.storageservice.util.HostSupplier; 26 | 27 | @JsonTypeName("signal-datadog") 28 | public class SignalDatadogReporterFactory extends BaseReporterFactory { 29 | 30 | @JsonProperty 31 | private String host = null; 32 | 33 | @JsonProperty 34 | private List tags = null; 35 | 36 | @Valid 37 | @JsonProperty 38 | private DynamicTagsCallbackFactory dynamicTagsCallback = null; 39 | 40 | @JsonProperty 41 | private String prefix = null; 42 | 43 | @Valid 44 | @NotNull 45 | @JsonProperty 46 | private EnumSet expansions = EnumSet.of( 47 | Expansion.COUNT, 48 | Expansion.MIN, 49 | Expansion.MAX, 50 | Expansion.MEAN, 51 | Expansion.MEDIAN, 52 | Expansion.P95, 53 | Expansion.P99 54 | ); 55 | 56 | @Valid 57 | @NotNull 58 | @JsonProperty 59 | private MetricNameFormatterFactory metricNameFormatter = new DefaultMetricNameFormatterFactory(); 60 | 61 | @Valid 62 | @NotNull 63 | @JsonProperty 64 | private AbstractTransportFactory transport = null; 65 | 66 | @Override 67 | public ScheduledReporter build(final MetricRegistry registry) { 68 | 69 | final List combinedTags; 70 | final String versionTag = "version:" + StorageServiceVersion.getServiceVersion(); 71 | 72 | if (tags != null) { 73 | combinedTags = new ArrayList<>(tags); 74 | combinedTags.add(versionTag); 75 | } else { 76 | combinedTags = new ArrayList<>((List.of(versionTag))); 77 | } 78 | 79 | return DatadogReporter.forRegistry(registry) 80 | .withTransport(transport.build()) 81 | .withHost(HostSupplier.getHost()) 82 | .withTags(combinedTags) 83 | .withPrefix(prefix) 84 | .withExpansions(expansions) 85 | .withMetricNameFormatter(metricNameFormatter.build()) 86 | .withDynamicTagCallback(dynamicTagsCallback != null ? dynamicTagsCallback.build() : null) 87 | .filter(getFilter()) 88 | .convertDurationsTo(getDurationUnit()) 89 | .convertRatesTo(getRateUnit()) 90 | .build(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/util/ua/UserAgentUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util.ua; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | import static org.junit.jupiter.api.Assertions.assertThrows; 10 | 11 | import com.vdurmont.semver4j.Semver; 12 | import java.util.stream.Stream; 13 | import javax.annotation.Nullable; 14 | import org.junit.jupiter.params.ParameterizedTest; 15 | import org.junit.jupiter.params.provider.Arguments; 16 | import org.junit.jupiter.params.provider.MethodSource; 17 | 18 | class UserAgentUtilTest { 19 | 20 | @ParameterizedTest 21 | @MethodSource("argumentsForTestParseStandardUserAgentString") 22 | void testParseStandardUserAgentString(final String userAgentString, @Nullable final UserAgent expectedUserAgent) 23 | throws UnrecognizedUserAgentException { 24 | 25 | if (expectedUserAgent != null) { 26 | assertEquals(expectedUserAgent, UserAgentUtil.parseUserAgentString(userAgentString)); 27 | } else { 28 | assertThrows(UnrecognizedUserAgentException.class, () -> UserAgentUtil.parseUserAgentString(userAgentString)); 29 | } 30 | } 31 | 32 | private static Stream argumentsForTestParseStandardUserAgentString() { 33 | return Stream.of( 34 | Arguments.of("This is obviously not a reasonable User-Agent string.", null), 35 | Arguments.of("Signal-Android/4.68.3 Android/25", 36 | new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25")), 37 | Arguments.of("Signal-Android/4.68.3", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), null)), 38 | Arguments.of("Signal-Desktop/1.2.3 Linux", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux")), 39 | Arguments.of("Signal-Desktop/1.2.3 macOS", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "macOS")), 40 | Arguments.of("Signal-Desktop/1.2.3 Windows", 41 | new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Windows")), 42 | Arguments.of("Signal-Desktop/1.2.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), null)), 43 | Arguments.of("Signal-Desktop/1.32.0-beta.3", 44 | new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3"), null)), 45 | Arguments.of("Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", 46 | new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)")), 47 | Arguments.of("Signal-iOS/3.9.0 iOS/14.2", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "iOS/14.2")), 48 | Arguments.of("Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), null)), 49 | Arguments.of("Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 tonic/0.31", 50 | new UserAgent(ClientPlatform.ANDROID, new Semver("7.11.23-nightly-1982-06-28-07-07-07"), "tonic/0.31")), 51 | Arguments.of("Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 Android/42 tonic/0.31", 52 | new UserAgent(ClientPlatform.ANDROID, new Semver("7.11.23-nightly-1982-06-28-07-07-07"), "Android/42 tonic/0.31")), 53 | Arguments.of("Signal-Android/7.6.2 Android/34 libsignal/0.46.0", 54 | new UserAgent(ClientPlatform.ANDROID, new Semver("7.6.2"), "Android/34 libsignal/0.46.0"))); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/GroupsTable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import com.codahale.metrics.MetricRegistry; 9 | import com.codahale.metrics.SharedMetricRegistries; 10 | import com.codahale.metrics.Timer; 11 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 12 | import com.google.cloud.bigtable.data.v2.models.Mutation; 13 | import com.google.protobuf.ByteString; 14 | import com.google.protobuf.InvalidProtocolBufferException; 15 | import org.signal.storageservice.metrics.StorageMetrics; 16 | import org.signal.storageservice.storage.protos.groups.Group; 17 | 18 | import java.util.Optional; 19 | import java.util.concurrent.CompletableFuture; 20 | 21 | import static com.codahale.metrics.MetricRegistry.name; 22 | 23 | public class GroupsTable extends Table { 24 | 25 | public static final String FAMILY = "g"; 26 | 27 | public static final String COLUMN_GROUP_DATA = "gr"; 28 | public static final String COLUMN_VERSION = "ver"; 29 | 30 | private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(StorageMetrics.NAME); 31 | private final Timer getTimer = metricRegistry.timer(name(GroupsTable.class, "get" )); 32 | private final Timer createTimer = metricRegistry.timer(name(GroupsTable.class, "create")); 33 | private final Timer updateTimer = metricRegistry.timer(name(GroupsTable.class, "update")); 34 | 35 | public GroupsTable(BigtableDataClient client, String tableId) { 36 | super(client, tableId); 37 | } 38 | 39 | public CompletableFuture> getGroup(ByteString groupId) { 40 | return toFuture(client.readRowAsync(tableId, groupId), getTimer).thenApply(row -> { 41 | if (row == null) return Optional.empty(); 42 | 43 | try { 44 | ByteString groupData = row.getCells(FAMILY, COLUMN_GROUP_DATA) 45 | .stream() 46 | .filter(cell -> cell.getTimestamp() == 0) 47 | .findFirst() 48 | .orElseThrow() 49 | .getValue(); 50 | 51 | return Optional.of(Group.parseFrom(groupData)); 52 | } catch (InvalidProtocolBufferException e) { 53 | throw new AssertionError(e); 54 | } 55 | }); 56 | } 57 | 58 | public CompletableFuture createGroup(ByteString groupId, Group group) { 59 | Mutation mutation = Mutation.create() 60 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_GROUP_DATA), 0, group.toByteString()) 61 | .setCell(FAMILY, COLUMN_VERSION, 0, String.valueOf(group.getVersion())); 62 | 63 | return setIfEmpty(createTimer, groupId, FAMILY, COLUMN_GROUP_DATA, mutation); 64 | } 65 | 66 | public CompletableFuture updateGroup(ByteString groupId, Group group) { 67 | Mutation mutation = Mutation.create() 68 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_GROUP_DATA), 0, group.toByteString()) 69 | .setCell(FAMILY, COLUMN_VERSION, 0, String.valueOf(group.getVersion())); 70 | 71 | return setIfValue(updateTimer, groupId, FAMILY, COLUMN_VERSION, String.valueOf(group.getVersion() - 1), mutation); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/metrics/UserAgentTagUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | 10 | import com.vdurmont.semver4j.Semver; 11 | import io.micrometer.core.instrument.Tag; 12 | import java.util.Map; 13 | import java.util.Optional; 14 | import java.util.Set; 15 | import java.util.stream.Stream; 16 | import org.junit.jupiter.api.Test; 17 | import org.junit.jupiter.params.ParameterizedTest; 18 | import org.junit.jupiter.params.provider.Arguments; 19 | import org.junit.jupiter.params.provider.MethodSource; 20 | import org.signal.storageservice.util.ua.ClientPlatform; 21 | 22 | class UserAgentTagUtilTest { 23 | 24 | @ParameterizedTest 25 | @MethodSource 26 | void getPlatformTag(final String userAgent, final Tag expectedTag) { 27 | assertEquals(expectedTag, UserAgentTagUtil.getPlatformTag(userAgent)); 28 | } 29 | 30 | private static Stream getPlatformTag() { 31 | return Stream.of( 32 | Arguments.of("This is obviously not a reasonable User-Agent string.", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "unrecognized")), 33 | Arguments.of(null, Tag.of(UserAgentTagUtil.PLATFORM_TAG, "unrecognized")), 34 | Arguments.of("Signal-Android/4.53.7 (Android 8.1)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), 35 | Arguments.of("Signal-Desktop/1.2.3", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), 36 | Arguments.of("Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "ios")), 37 | Arguments.of("Signal-Android/1.2.3 (Android 8.1)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), 38 | Arguments.of("Signal-Desktop/3.9.0", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), 39 | Arguments.of("Signal-iOS/4.53.7 (iPhone; iOS 12.2; Scale/3.00)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "ios")), 40 | Arguments.of("Signal-Android/4.68.3 (Android 9)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), 41 | Arguments.of("Signal-Android/1.2.3 (Android 4.3)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), 42 | Arguments.of("Signal-Android/4.68.3.0-bobsbootlegclient", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), 43 | Arguments.of("Signal-Desktop/1.22.45-foo-0", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), 44 | Arguments.of("Signal-Desktop/1.34.5-beta.1-fakeclientemporium", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), 45 | Arguments.of("Signal-Desktop/1.32.0-beta.3", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")) 46 | ); 47 | } 48 | 49 | @Test 50 | void getClientVersionTag() { 51 | final Map> recognizedClientVersions = 52 | Map.of(ClientPlatform.ANDROID, Set.of(new Semver("1.2.3"))); 53 | 54 | assertEquals(Optional.of(Tag.of(UserAgentTagUtil.VERSION_TAG, "1.2.3")), 55 | UserAgentTagUtil.getClientVersionTag("Signal-Android/1.2.3 (Android 4.3)", recognizedClientVersions)); 56 | 57 | assertEquals(Optional.empty(), 58 | UserAgentTagUtil.getClientVersionTag("Signal-Android/1.2.4 (Android 4.3)", recognizedClientVersions)); 59 | 60 | assertEquals(Optional.empty(), 61 | UserAgentTagUtil.getClientVersionTag("Signal-Desktop/1.22.45-foo-0", recognizedClientVersions)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/GroupsManager.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 9 | import com.google.protobuf.ByteString; 10 | import java.util.List; 11 | import java.util.Optional; 12 | import java.util.concurrent.CompletableFuture; 13 | import org.signal.storageservice.storage.protos.groups.Group; 14 | import org.signal.storageservice.storage.protos.groups.GroupChange; 15 | import org.signal.storageservice.storage.protos.groups.GroupChanges.GroupChangeState; 16 | import javax.annotation.Nullable; 17 | 18 | public class GroupsManager { 19 | 20 | private final GroupsTable groupsTable; 21 | private final GroupLogTable groupLogTable; 22 | 23 | public GroupsManager(BigtableDataClient client, String groupsTableId, String groupLogsTableId) { 24 | this.groupsTable = new GroupsTable (client, groupsTableId ); 25 | this.groupLogTable = new GroupLogTable(client, groupLogsTableId); 26 | } 27 | 28 | public CompletableFuture> getGroup(ByteString groupId) { 29 | return groupsTable.getGroup(groupId); 30 | } 31 | 32 | public CompletableFuture createGroup(ByteString groupId, Group group) { 33 | return groupsTable.createGroup(groupId, group); 34 | } 35 | 36 | public CompletableFuture> updateGroup(ByteString groupId, Group group) { 37 | return groupsTable.updateGroup(groupId, group) 38 | .thenCompose(modified -> { 39 | if (modified) return CompletableFuture.completedFuture(Optional.empty()); 40 | else return getGroup(groupId).thenApply(result -> Optional.of(result.orElseThrow())); 41 | }); 42 | } 43 | 44 | public CompletableFuture> getChangeRecords(ByteString groupId, Group group, 45 | @Nullable Integer maxSupportedChangeEpoch, boolean includeFirstState, boolean includeLastState, 46 | int fromVersionInclusive, int toVersionExclusive) { 47 | if (fromVersionInclusive >= toVersionExclusive) { 48 | throw new IllegalArgumentException("Version to read from (" + fromVersionInclusive + ") must be less than version to read to (" + toVersionExclusive + ")"); 49 | } 50 | 51 | return groupLogTable.getRecordsFromVersion(groupId, maxSupportedChangeEpoch, includeFirstState, includeLastState, fromVersionInclusive, toVersionExclusive, group.getVersion()) 52 | .thenApply(groupChangeStatesAndSeenCurrentVersion -> { 53 | List groupChangeStates = groupChangeStatesAndSeenCurrentVersion.first(); 54 | boolean seenCurrentVersion = groupChangeStatesAndSeenCurrentVersion.second(); 55 | if (isGroupInRange(group, fromVersionInclusive, toVersionExclusive) && !seenCurrentVersion && toVersionExclusive - 1 == group.getVersion()) { 56 | groupChangeStates.add(GroupChangeState.newBuilder().setGroupState(group).build()); 57 | } 58 | return groupChangeStates; 59 | }); 60 | } 61 | 62 | public CompletableFuture appendChangeRecord(ByteString groupId, int version, GroupChange change, Group state) { 63 | return groupLogTable.append(groupId, version, change, state); 64 | } 65 | 66 | private static boolean isGroupInRange(Group group, int fromVersionInclusive, int toVersionExclusive) { 67 | return fromVersionInclusive <= group.getVersion() && group.getVersion() < toVersionExclusive; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/MetricsUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.codahale.metrics.SharedMetricRegistries; 9 | import io.dropwizard.core.setup.Environment; 10 | import io.micrometer.core.instrument.Metrics; 11 | import io.micrometer.core.instrument.Tags; 12 | import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; 13 | import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; 14 | import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics; 15 | import io.micrometer.core.instrument.binder.system.ProcessorMetrics; 16 | import io.micrometer.datadog.DatadogMeterRegistry; 17 | import org.signal.storageservice.StorageServiceConfiguration; 18 | import org.signal.storageservice.StorageServiceVersion; 19 | import org.signal.storageservice.util.HostSupplier; 20 | 21 | public class MetricsUtil { 22 | 23 | public static final String PREFIX = "storage"; 24 | 25 | private static volatile boolean registeredMetrics = false; 26 | 27 | /** 28 | * Returns a dot-separated ('.') name for the given class and name parts 29 | */ 30 | public static String name(Class clazz, String... parts) { 31 | return name(clazz.getSimpleName(), parts); 32 | } 33 | 34 | private static String name(String name, String... parts) { 35 | final StringBuilder sb = new StringBuilder(PREFIX); 36 | sb.append(".").append(name); 37 | for (String part : parts) { 38 | sb.append(".").append(part); 39 | } 40 | return sb.toString(); 41 | } 42 | 43 | public static void configureRegistries(final StorageServiceConfiguration config, final Environment environment) { 44 | 45 | if (registeredMetrics) { 46 | throw new IllegalStateException("Metric registries configured more than once"); 47 | } 48 | 49 | registeredMetrics = true; 50 | 51 | SharedMetricRegistries.add(StorageMetrics.NAME, environment.metrics()); 52 | 53 | { 54 | final DatadogMeterRegistry datadogMeterRegistry = new DatadogMeterRegistry( 55 | config.getDatadogConfiguration(), io.micrometer.core.instrument.Clock.SYSTEM); 56 | 57 | datadogMeterRegistry.config().commonTags( 58 | Tags.of( 59 | "service", "storage", 60 | "host", HostSupplier.getHost(), 61 | "version", StorageServiceVersion.getServiceVersion(), 62 | "env", config.getDatadogConfiguration().getEnvironment())); 63 | 64 | Metrics.addRegistry(datadogMeterRegistry); 65 | } 66 | } 67 | 68 | 69 | public static void registerSystemResourceMetrics(final Environment environment) { 70 | // Dropwizard metrics - some are temporarily duplicated for continuity 71 | environment.metrics().register(name(CpuUsageGauge.class, "cpu"), new CpuUsageGauge()); 72 | environment.metrics().register(name(FreeMemoryGauge.class, "free_memory"), new FreeMemoryGauge()); 73 | environment.metrics().register(name(NetworkSentGauge.class, "bytes_sent"), new NetworkSentGauge()); 74 | environment.metrics().register(name(NetworkReceivedGauge.class, "bytes_received"), new NetworkReceivedGauge()); 75 | environment.metrics().register(name(FileDescriptorGauge.class, "fd_count"), new FileDescriptorGauge()); 76 | 77 | // Micrometer metrics 78 | new ProcessorMetrics().bindTo(Metrics.globalRegistry); 79 | new FreeMemoryGauge().bindTo(Metrics.globalRegistry); 80 | new FileDescriptorMetrics().bindTo(Metrics.globalRegistry); 81 | 82 | new JvmMemoryMetrics().bindTo(Metrics.globalRegistry); 83 | new JvmThreadMetrics().bindTo(Metrics.globalRegistry); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/LogstashTcpSocketAppenderFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import ch.qos.logback.classic.LoggerContext; 9 | import ch.qos.logback.classic.PatternLayout; 10 | import ch.qos.logback.classic.spi.ILoggingEvent; 11 | import ch.qos.logback.core.Appender; 12 | import ch.qos.logback.core.encoder.LayoutWrappingEncoder; 13 | import ch.qos.logback.core.net.ssl.SSLConfiguration; 14 | import com.fasterxml.jackson.annotation.JsonProperty; 15 | import com.fasterxml.jackson.annotation.JsonTypeName; 16 | import com.fasterxml.jackson.databind.node.JsonNodeFactory; 17 | import com.fasterxml.jackson.databind.node.ObjectNode; 18 | import com.fasterxml.jackson.databind.node.TextNode; 19 | import io.dropwizard.logging.common.AbstractAppenderFactory; 20 | import io.dropwizard.logging.common.async.AsyncAppenderFactory; 21 | import io.dropwizard.logging.common.filter.LevelFilterFactory; 22 | import io.dropwizard.logging.common.layout.LayoutFactory; 23 | import java.time.Duration; 24 | import jakarta.validation.constraints.NotEmpty; 25 | import net.logstash.logback.appender.LogstashTcpSocketAppender; 26 | import net.logstash.logback.encoder.LogstashEncoder; 27 | import org.signal.storageservice.StorageServiceVersion; 28 | import org.signal.storageservice.util.HostSupplier; 29 | 30 | @JsonTypeName("logstashtcpsocket") 31 | public class LogstashTcpSocketAppenderFactory extends AbstractAppenderFactory { 32 | 33 | private String destination; 34 | private Duration keepAlive = Duration.ofSeconds(20); 35 | private String apiKey; 36 | private String environment; 37 | 38 | @JsonProperty 39 | @NotEmpty 40 | public String getDestination() { 41 | return destination; 42 | } 43 | 44 | @JsonProperty 45 | public Duration getKeepAlive() { 46 | return keepAlive; 47 | } 48 | 49 | @JsonProperty 50 | @NotEmpty 51 | public String getApiKey() { 52 | return apiKey; 53 | } 54 | 55 | @JsonProperty 56 | @NotEmpty 57 | public String getEnvironment() { 58 | return environment; 59 | } 60 | 61 | @Override 62 | public Appender build( 63 | final LoggerContext context, 64 | final String applicationName, 65 | final LayoutFactory layoutFactory, 66 | final LevelFilterFactory levelFilterFactory, 67 | final AsyncAppenderFactory asyncAppenderFactory) { 68 | 69 | final SSLConfiguration sslConfiguration = new SSLConfiguration(); 70 | final LogstashTcpSocketAppender appender = new LogstashTcpSocketAppender(); 71 | appender.setName("logstashtcpsocket-appender"); 72 | appender.setContext(context); 73 | appender.setSsl(sslConfiguration); 74 | appender.addDestination(destination); 75 | appender.setKeepAliveDuration(new ch.qos.logback.core.util.Duration(keepAlive.toMillis())); 76 | 77 | final LogstashEncoder encoder = new LogstashEncoder(); 78 | final ObjectNode customFieldsNode = new ObjectNode(JsonNodeFactory.instance); 79 | customFieldsNode.set("host", TextNode.valueOf(HostSupplier.getHost())); 80 | customFieldsNode.set("service", TextNode.valueOf("storage")); 81 | customFieldsNode.set("ddsource", TextNode.valueOf("logstash")); 82 | customFieldsNode.set("ddtags", TextNode.valueOf("env:" + environment + ",version:" + StorageServiceVersion.getServiceVersion())); 83 | encoder.setCustomFields(customFieldsNode.toString()); 84 | final LayoutWrappingEncoder prefix = new LayoutWrappingEncoder<>(); 85 | final PatternLayout layout = new PatternLayout(); 86 | layout.setPattern(String.format("%s ", apiKey)); 87 | prefix.setLayout(layout); 88 | encoder.setPrefix(prefix); 89 | appender.setEncoder(encoder); 90 | 91 | appender.addFilter(levelFilterFactory.build(threshold)); 92 | getFilterFactories().forEach(f -> appender.addFilter(f.build())); 93 | appender.start(); 94 | 95 | return wrapAsync(appender, asyncAppenderFactory); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/auth/GroupUserAuthenticatorTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2013-2022 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.auth; 7 | 8 | import com.google.protobuf.ByteString; 9 | import io.dropwizard.auth.basic.BasicCredentials; 10 | import org.apache.commons.codec.binary.Hex; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.signal.libsignal.protocol.ServiceId.Aci; 14 | import org.signal.libsignal.protocol.ServiceId.Pni; 15 | import org.signal.libsignal.zkgroup.ServerSecretParams; 16 | import org.signal.libsignal.zkgroup.auth.AuthCredentialPresentation; 17 | import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPni; 18 | import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse; 19 | import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations; 20 | import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; 21 | import org.signal.libsignal.zkgroup.groups.GroupPublicParams; 22 | import org.signal.libsignal.zkgroup.groups.GroupSecretParams; 23 | 24 | import java.time.Instant; 25 | import java.time.temporal.ChronoUnit; 26 | import java.util.Optional; 27 | import java.util.UUID; 28 | 29 | import static org.junit.jupiter.api.Assertions.assertAll; 30 | import static org.junit.jupiter.api.Assertions.assertEquals; 31 | import static org.junit.jupiter.api.Assertions.assertTrue; 32 | 33 | class GroupUserAuthenticatorTest { 34 | 35 | private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate(); 36 | 37 | private static final GroupSecretParams GROUP_SECRET_PARAMS = GroupSecretParams.generate(); 38 | private static final GroupPublicParams GROUP_PUBLIC_PARAMS = GROUP_SECRET_PARAMS.getPublicParams(); 39 | 40 | private static final byte[] GROUP_ID = GROUP_PUBLIC_PARAMS.serialize(); 41 | private static final byte[] GROUP_PUBLIC_KEY = GROUP_PUBLIC_PARAMS.serialize(); 42 | 43 | private GroupUserAuthenticator groupUserAuthenticator; 44 | 45 | @BeforeEach 46 | void setUp() { 47 | groupUserAuthenticator = new GroupUserAuthenticator(new ServerZkAuthOperations(SERVER_SECRET_PARAMS)); 48 | } 49 | 50 | @Test 51 | void authenticate() throws Exception { 52 | final Aci aci = new Aci(UUID.randomUUID()); 53 | final Pni pni = new Pni(UUID.randomUUID()); 54 | 55 | final Instant redemptionInstant = Instant.now().truncatedTo(ChronoUnit.DAYS); 56 | 57 | final ServerZkAuthOperations serverZkAuthOperations = new ServerZkAuthOperations(SERVER_SECRET_PARAMS); 58 | final ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams()); 59 | 60 | final AuthCredentialWithPniResponse authCredentialWithPniResponse = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemptionInstant); 61 | final AuthCredentialWithPni authCredentialWithPni = clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId(aci, pni, redemptionInstant.getEpochSecond(), authCredentialWithPniResponse); 62 | final AuthCredentialPresentation authCredentialPresentation = clientZkAuthOperations.createAuthCredentialPresentation(GROUP_SECRET_PARAMS, authCredentialWithPni); 63 | 64 | final byte[] presentation = authCredentialPresentation.serialize(); 65 | final GroupUser expectedGroupUser = new GroupUser( 66 | ByteString.copyFrom(authCredentialPresentation.getUuidCiphertext().serialize()), 67 | ByteString.copyFrom(authCredentialPresentation.getPniCiphertext().serialize()), 68 | ByteString.copyFrom(GROUP_PUBLIC_KEY), 69 | ByteString.copyFrom(GROUP_ID)); 70 | 71 | final BasicCredentials basicCredentials = 72 | new BasicCredentials(Hex.encodeHexString(GROUP_PUBLIC_KEY), Hex.encodeHexString(presentation)); 73 | 74 | final Optional maybeAuthenticatedUser = groupUserAuthenticator.authenticate(basicCredentials); 75 | 76 | assertTrue(maybeAuthenticatedUser.isPresent()); 77 | assertGroupUserEqual(expectedGroupUser, maybeAuthenticatedUser.get()); 78 | } 79 | 80 | private static void assertGroupUserEqual(final GroupUser expected, final GroupUser actual) { 81 | assertAll( 82 | () -> assertEquals(expected.getAciCiphertext(), actual.getAciCiphertext()), 83 | () -> assertEquals(expected.getPniCiphertext(), actual.getPniCiphertext()) 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/StorageManager.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 9 | import com.google.protobuf.ByteString; 10 | import org.signal.storageservice.auth.User; 11 | import org.signal.storageservice.storage.protos.contacts.StorageItem; 12 | import org.signal.storageservice.storage.protos.contacts.StorageManifest; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.util.List; 17 | import java.util.Optional; 18 | import java.util.concurrent.CompletableFuture; 19 | 20 | public class StorageManager { 21 | 22 | private final StorageManifestsTable manifestsTable; 23 | private final StorageItemsTable itemsTable; 24 | 25 | private static final Logger log = LoggerFactory.getLogger(StorageManager.class); 26 | 27 | public StorageManager(BigtableDataClient client, String contactManifestsTableId, String contactsTableId) { 28 | this.manifestsTable = new StorageManifestsTable(client, contactManifestsTableId); 29 | this.itemsTable = new StorageItemsTable(client, contactsTableId); 30 | } 31 | 32 | /** 33 | * Updates a manifest and applies mutations to stored items. 34 | * 35 | * @param user the user for whom to update manifests and mutate stored items 36 | * @param manifest the new manifest to store 37 | * @param inserts a list of new items to store 38 | * @param deletes a list of item identifiers to delete 39 | * 40 | * @return a future that completes when all updates and mutations have been applied; the future yields an empty value 41 | * if all updates and mutations were applied successfully, or the latest stored version of the {@code StorageManifest} 42 | * if the given {@code manifest}'s version is not exactly one version ahead of the stored manifest 43 | * 44 | * @see StorageManifestsTable#set(User, StorageManifest) 45 | */ 46 | public CompletableFuture> set(User user, StorageManifest manifest, List inserts, List deletes) { 47 | return manifestsTable.set(user, manifest) 48 | .thenCompose(manifestUpdated -> { 49 | if (manifestUpdated) { 50 | return inserts.isEmpty() && deletes.isEmpty() 51 | ? CompletableFuture.completedFuture(Optional.empty()) 52 | : itemsTable.set(user, inserts, deletes).thenApply(nothing -> Optional.empty()); 53 | } else { 54 | // The new manifest's version wasn't the expected value, and it's likely that the manifest 55 | // was updated by a separate thread/process. Return a copy of the most recent stored 56 | // manifest. 57 | return getManifest(user).thenApply(retrieved -> Optional.of(retrieved.orElseThrow())); 58 | } 59 | }); 60 | } 61 | 62 | public CompletableFuture> getManifest(User user) { 63 | return manifestsTable.get(user); 64 | } 65 | 66 | public CompletableFuture> getManifestIfNotVersion(User user, long version) { 67 | return manifestsTable.getIfNotVersion(user, version); 68 | } 69 | 70 | public CompletableFuture> getItems(User user, List keys) { 71 | return itemsTable.get(user, keys); 72 | } 73 | 74 | public CompletableFuture clearItems(User user) { 75 | return itemsTable.clear(user).whenComplete((ignored, throwable) -> { 76 | if (throwable != null) { 77 | log.warn("Failed to clear stored items", throwable); 78 | } 79 | }); 80 | } 81 | 82 | public CompletableFuture delete(User user) { 83 | return CompletableFuture.allOf( 84 | itemsTable.clear(user).whenComplete((ignored, throwable) -> { 85 | if (throwable != null) { 86 | log.warn("Failed to delete stored items", throwable); 87 | } 88 | }), 89 | 90 | manifestsTable.clear(user).whenComplete((ignored, throwable) -> { 91 | if (throwable != null) { 92 | log.warn("Failed to delete manifest", throwable); 93 | } 94 | })); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/Util.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | import java.io.UnsupportedEncodingException; 9 | import java.net.URLEncoder; 10 | import java.security.SecureRandom; 11 | import java.util.Arrays; 12 | import java.util.Map; 13 | import java.util.concurrent.TimeUnit; 14 | 15 | public class Util { 16 | 17 | public static int currentDaysSinceEpoch() { 18 | return Util.toIntExact(System.currentTimeMillis() / 1000 / 60 / 60 / 24); 19 | } 20 | 21 | public static int toIntExact(long value) { 22 | if ((int)value != value) { 23 | throw new ArithmeticException("integer overflow"); 24 | } 25 | return (int)value; 26 | } 27 | 28 | public static String encodeFormParams(Map params) { 29 | try { 30 | StringBuffer buffer = new StringBuffer(); 31 | 32 | for (String key : params.keySet()) { 33 | buffer.append(String.format("%s=%s", 34 | URLEncoder.encode(key, "UTF-8"), 35 | URLEncoder.encode(params.get(key), "UTF-8"))); 36 | buffer.append("&"); 37 | } 38 | 39 | buffer.deleteCharAt(buffer.length()-1); 40 | return buffer.toString(); 41 | } catch (UnsupportedEncodingException e) { 42 | throw new AssertionError(e); 43 | } 44 | } 45 | 46 | public static boolean isEmpty(String param) { 47 | return param == null || param.length() == 0; 48 | } 49 | 50 | public static byte[] combine(byte[] one, byte[] two, byte[] three, byte[] four) { 51 | byte[] combined = new byte[one.length + two.length + three.length + four.length]; 52 | System.arraycopy(one, 0, combined, 0, one.length); 53 | System.arraycopy(two, 0, combined, one.length, two.length); 54 | System.arraycopy(three, 0, combined, one.length + two.length, three.length); 55 | System.arraycopy(four, 0, combined, one.length + two.length + three.length, four.length); 56 | 57 | return combined; 58 | } 59 | 60 | public static byte[] truncate(byte[] element, int length) { 61 | byte[] result = new byte[length]; 62 | System.arraycopy(element, 0, result, 0, result.length); 63 | 64 | return result; 65 | } 66 | 67 | 68 | public static byte[][] split(byte[] input, int firstLength, int secondLength) { 69 | byte[][] parts = new byte[2][]; 70 | 71 | parts[0] = new byte[firstLength]; 72 | System.arraycopy(input, 0, parts[0], 0, firstLength); 73 | 74 | parts[1] = new byte[secondLength]; 75 | System.arraycopy(input, firstLength, parts[1], 0, secondLength); 76 | 77 | return parts; 78 | } 79 | 80 | public static byte[][] split(byte[] input, int firstLength, int secondLength, int thirdLength, int fourthLength) { 81 | byte[][] parts = new byte[4][]; 82 | 83 | parts[0] = new byte[firstLength]; 84 | System.arraycopy(input, 0, parts[0], 0, firstLength); 85 | 86 | parts[1] = new byte[secondLength]; 87 | System.arraycopy(input, firstLength, parts[1], 0, secondLength); 88 | 89 | parts[2] = new byte[thirdLength]; 90 | System.arraycopy(input, firstLength + secondLength, parts[2], 0, thirdLength); 91 | 92 | parts[3] = new byte[fourthLength]; 93 | System.arraycopy(input, firstLength + secondLength + thirdLength, parts[3], 0, fourthLength); 94 | 95 | return parts; 96 | } 97 | 98 | public static byte[] generateSecretBytes(int size) { 99 | byte[] data = new byte[size]; 100 | new SecureRandom().nextBytes(data); 101 | return data; 102 | } 103 | 104 | public static void sleep(long i) { 105 | try { 106 | Thread.sleep(i); 107 | } catch (InterruptedException ie) {} 108 | } 109 | 110 | public static void wait(Object object) { 111 | try { 112 | object.wait(); 113 | } catch (InterruptedException e) { 114 | throw new AssertionError(e); 115 | } 116 | } 117 | 118 | public static void wait(Object object, long timeoutMs) { 119 | try { 120 | object.wait(timeoutMs); 121 | } catch (InterruptedException e) { 122 | throw new AssertionError(e); 123 | } 124 | } 125 | 126 | public static int hashCode(Object... objects) { 127 | return Arrays.hashCode(objects); 128 | } 129 | 130 | public static boolean isEquals(Object first, Object second) { 131 | return (first == null && second == null) || (first == second) || (first != null && first.equals(second)); 132 | } 133 | 134 | public static long todayInMillis() { 135 | return TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/test/java/org/signal/storageservice/metrics/MetricsHttpChannelListenerTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | import static org.mockito.ArgumentMatchers.any; 11 | import static org.mockito.ArgumentMatchers.eq; 12 | import static org.mockito.Mockito.mock; 13 | import static org.mockito.Mockito.verify; 14 | import static org.mockito.Mockito.when; 15 | 16 | import com.google.common.net.HttpHeaders; 17 | import io.micrometer.core.instrument.Counter; 18 | import io.micrometer.core.instrument.MeterRegistry; 19 | import io.micrometer.core.instrument.Tag; 20 | import java.util.HashSet; 21 | import java.util.List; 22 | import java.util.Set; 23 | import org.eclipse.jetty.http.HttpURI; 24 | import org.eclipse.jetty.server.Request; 25 | import org.eclipse.jetty.server.Response; 26 | import org.glassfish.jersey.server.ExtendedUriInfo; 27 | import org.glassfish.jersey.uri.UriTemplate; 28 | import org.junit.jupiter.api.BeforeEach; 29 | import org.junit.jupiter.api.Test; 30 | import org.mockito.ArgumentCaptor; 31 | 32 | class MetricsHttpChannelListenerTest { 33 | 34 | private MeterRegistry meterRegistry; 35 | private Counter requestCounter; 36 | private Counter requestBytesCounter; 37 | private Counter responseBytesCounter; 38 | private MetricsHttpChannelListener listener; 39 | 40 | @BeforeEach 41 | void setup() { 42 | meterRegistry = mock(MeterRegistry.class); 43 | requestCounter = mock(Counter.class); 44 | requestBytesCounter = mock(Counter.class); 45 | responseBytesCounter = mock(Counter.class); 46 | 47 | //noinspection unchecked 48 | when(meterRegistry.counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), any(Iterable.class))) 49 | .thenReturn(requestCounter); 50 | 51 | //noinspection unchecked 52 | when(meterRegistry.counter(eq(MetricsHttpChannelListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class))) 53 | .thenReturn(requestBytesCounter); 54 | 55 | //noinspection unchecked 56 | when(meterRegistry.counter(eq(MetricsHttpChannelListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class))) 57 | .thenReturn(responseBytesCounter); 58 | 59 | listener = new MetricsHttpChannelListener(meterRegistry); 60 | } 61 | 62 | @Test 63 | @SuppressWarnings("unchecked") 64 | void testRequests() { 65 | final String path = "/test"; 66 | final String method = "GET"; 67 | final int statusCode = 200; 68 | final long requestContentLength = 5; 69 | final long responseContentLength = 7; 70 | 71 | final HttpURI httpUri = mock(HttpURI.class); 72 | when(httpUri.getPath()).thenReturn(path); 73 | 74 | final Request request = mock(Request.class); 75 | when(request.getMethod()).thenReturn(method); 76 | when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn("Signal-Android/4.53.7 (Android 8.1)"); 77 | when(request.getHttpURI()).thenReturn(httpUri); 78 | when(request.getContentRead()).thenReturn(requestContentLength); 79 | 80 | final Response response = mock(Response.class); 81 | when(response.getStatus()).thenReturn(statusCode); 82 | when(response.getContentCount()).thenReturn(responseContentLength); 83 | when(request.getResponse()).thenReturn(response); 84 | final ExtendedUriInfo extendedUriInfo = mock(ExtendedUriInfo.class); 85 | when(request.getAttribute(MetricsHttpChannelListener.URI_INFO_PROPERTY_NAME)).thenReturn(extendedUriInfo); 86 | when(extendedUriInfo.getMatchedTemplates()).thenReturn(List.of(new UriTemplate(path))); 87 | 88 | final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); 89 | 90 | listener.onComplete(request); 91 | 92 | verify(requestCounter).increment(); 93 | verify(requestBytesCounter).increment(requestContentLength); 94 | verify(responseBytesCounter).increment(responseContentLength); 95 | 96 | verify(meterRegistry).counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); 97 | 98 | final Set tags = new HashSet<>(); 99 | for (final Tag tag : tagCaptor.getValue()) { 100 | tags.add(tag); 101 | } 102 | 103 | assertEquals(4, tags.size()); 104 | assertTrue(tags.contains(Tag.of(MetricsHttpChannelListener.PATH_TAG, path))); 105 | assertTrue(tags.contains(Tag.of(MetricsHttpChannelListener.METHOD_TAG, method))); 106 | assertTrue(tags.contains(Tag.of(MetricsHttpChannelListener.STATUS_CODE_TAG, String.valueOf(statusCode)))); 107 | assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android"))); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/controllers/GroupsV1Controller.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.controllers; 7 | 8 | import com.codahale.metrics.annotation.Timed; 9 | import io.dropwizard.auth.Auth; 10 | import java.time.Clock; 11 | import java.time.Instant; 12 | import java.util.Optional; 13 | import java.util.concurrent.CompletableFuture; 14 | import jakarta.ws.rs.Consumes; 15 | import jakarta.ws.rs.DefaultValue; 16 | import jakarta.ws.rs.GET; 17 | import jakarta.ws.rs.HeaderParam; 18 | import jakarta.ws.rs.PATCH; 19 | import jakarta.ws.rs.PUT; 20 | import jakarta.ws.rs.Path; 21 | import jakarta.ws.rs.PathParam; 22 | import jakarta.ws.rs.Produces; 23 | import jakarta.ws.rs.QueryParam; 24 | import jakarta.ws.rs.core.Response; 25 | import org.signal.libsignal.zkgroup.ServerSecretParams; 26 | import org.signal.storageservice.auth.ExternalGroupCredentialGenerator; 27 | import org.signal.storageservice.auth.GroupUser; 28 | import org.signal.storageservice.configuration.GroupConfiguration; 29 | import org.signal.storageservice.providers.NoUnknownFields; 30 | import org.signal.storageservice.providers.ProtocolBufferMediaType; 31 | import org.signal.storageservice.s3.PolicySigner; 32 | import org.signal.storageservice.s3.PostPolicyGenerator; 33 | import org.signal.storageservice.storage.GroupsManager; 34 | import org.signal.storageservice.storage.protos.groups.Group; 35 | import org.signal.storageservice.storage.protos.groups.GroupChange; 36 | import org.signal.storageservice.storage.protos.groups.GroupChangeResponse; 37 | import org.signal.storageservice.storage.protos.groups.GroupResponse; 38 | import org.signal.storageservice.storage.protos.groups.GroupChanges; 39 | 40 | @Path("/v1/groups") 41 | public class GroupsV1Controller extends GroupsController { 42 | 43 | public GroupsV1Controller( 44 | Clock clock, 45 | GroupsManager groupsManager, 46 | ServerSecretParams serverSecretParams, 47 | PolicySigner policySigner, 48 | PostPolicyGenerator policyGenerator, 49 | GroupConfiguration groupConfiguration, 50 | ExternalGroupCredentialGenerator externalGroupCredentialGenerator) { 51 | super(clock, groupsManager, serverSecretParams, policySigner, policyGenerator, groupConfiguration, externalGroupCredentialGenerator); 52 | } 53 | 54 | @Override 55 | @Timed 56 | @GET 57 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 58 | public CompletableFuture getGroup(@Auth GroupUser user) { 59 | return super.getGroup(user) 60 | .thenApply(response -> { 61 | if (response.getEntity() instanceof final GroupResponse gr) { 62 | return Response.fromResponse(response).entity(gr.getGroup()).build(); 63 | } 64 | return response; 65 | }); 66 | } 67 | 68 | @Override 69 | @Timed 70 | @GET 71 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 72 | @Path("/logs/{fromVersion}") 73 | public CompletableFuture getGroupLogs( 74 | @Auth GroupUser user, 75 | @HeaderParam(jakarta.ws.rs.core.HttpHeaders.USER_AGENT) String userAgent, 76 | @HeaderParam("Cached-Send-Endorsements") Long ignored_usedByV2Only, 77 | @PathParam("fromVersion") int fromVersion, 78 | @QueryParam("limit") @DefaultValue("64") int limit, 79 | @QueryParam("maxSupportedChangeEpoch") Optional maxSupportedChangeEpoch, 80 | @QueryParam("includeFirstState") boolean includeFirstState, 81 | @QueryParam("includeLastState") boolean includeLastState) { 82 | return super.getGroupLogs(user, userAgent, Instant.now().getEpochSecond(), fromVersion, limit, maxSupportedChangeEpoch, includeFirstState, includeLastState) 83 | .thenApply(response -> { 84 | if (response.getEntity() instanceof final GroupChanges gc) { 85 | return Response.fromResponse(response).entity(gc.toBuilder().clearGroupSendEndorsementsResponse().build()).build(); 86 | } 87 | return response; 88 | }); 89 | } 90 | 91 | @Override 92 | @Timed 93 | @PUT 94 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 95 | @Consumes(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 96 | public CompletableFuture createGroup(@Auth GroupUser user, @NoUnknownFields Group group) { 97 | return super.createGroup(user, group) 98 | .thenApply(response -> Response.fromResponse(response).entity(null).build()); 99 | } 100 | 101 | @Override 102 | @Timed 103 | @PATCH 104 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 105 | @Consumes(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 106 | public CompletableFuture modifyGroup( 107 | @Auth GroupUser user, 108 | @HeaderParam(jakarta.ws.rs.core.HttpHeaders.USER_AGENT) String userAgent, 109 | @QueryParam("inviteLinkPassword") String inviteLinkPasswordString, 110 | @NoUnknownFields GroupChange.Actions submittedActions) { 111 | return super.modifyGroup(user, userAgent, inviteLinkPasswordString, submittedActions) 112 | .thenApply(response -> { 113 | if (response.getEntity() instanceof final GroupChangeResponse gcr) { 114 | return Response.fromResponse(response).entity(gcr.getGroupChange()).build(); 115 | } 116 | return response; 117 | }); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/GroupLogTable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import static com.codahale.metrics.MetricRegistry.name; 9 | 10 | import com.codahale.metrics.MetricRegistry; 11 | import com.codahale.metrics.SharedMetricRegistries; 12 | import com.codahale.metrics.Timer; 13 | import com.google.api.gax.rpc.ResponseObserver; 14 | import com.google.api.gax.rpc.StreamController; 15 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 16 | import com.google.cloud.bigtable.data.v2.models.Mutation; 17 | import com.google.cloud.bigtable.data.v2.models.Query; 18 | import com.google.cloud.bigtable.data.v2.models.Row; 19 | import com.google.protobuf.ByteString; 20 | import com.google.protobuf.InvalidProtocolBufferException; 21 | import java.util.LinkedList; 22 | import java.util.List; 23 | import java.util.concurrent.CompletableFuture; 24 | import javax.annotation.Nullable; 25 | import org.signal.storageservice.metrics.StorageMetrics; 26 | import org.signal.storageservice.storage.protos.groups.Group; 27 | import org.signal.storageservice.storage.protos.groups.GroupChange; 28 | import org.signal.storageservice.storage.protos.groups.GroupChanges.GroupChangeState; 29 | import org.signal.storageservice.util.Conversions; 30 | import org.signal.storageservice.util.Pair; 31 | 32 | public class GroupLogTable extends Table { 33 | 34 | public static final String FAMILY = "l"; 35 | 36 | public static final String COLUMN_VERSION = "v"; 37 | public static final String COLUMN_CHANGE = "c"; 38 | public static final String COLUMN_STATE = "s"; 39 | 40 | private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(StorageMetrics.NAME); 41 | private final Timer appendTimer = metricRegistry.timer(name(GroupLogTable.class, "append" )); 42 | private final Timer getFromVersionTimer = metricRegistry.timer(name(GroupLogTable.class, "getFromVersion")); 43 | 44 | public GroupLogTable(BigtableDataClient client, String tableId) { 45 | super(client, tableId); 46 | } 47 | 48 | public CompletableFuture append(ByteString groupId, int version, GroupChange groupChange, Group group) { 49 | return setIfEmpty(appendTimer, 50 | getRowId(groupId, version), 51 | FAMILY, COLUMN_CHANGE, 52 | Mutation.create() 53 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_CHANGE), 0L, groupChange.toByteString()) 54 | .setCell(FAMILY, COLUMN_VERSION, 0, String.valueOf(version)) 55 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_STATE), 0L, group.toByteString())); 56 | } 57 | 58 | public CompletableFuture, Boolean>> getRecordsFromVersion(ByteString groupId, 59 | @Nullable Integer maxSupportedChangeEpoch, boolean includeFirstState, boolean includeLastState, 60 | int fromVersionInclusive, int toVersionExclusive, int currentVersion) { 61 | 62 | Timer.Context timerContext = getFromVersionTimer.time(); 63 | CompletableFuture, Boolean>> future = new CompletableFuture<>(); 64 | Query query = Query.create(tableId); 65 | 66 | query.range(getRowId(groupId, fromVersionInclusive), getRowId(groupId, toVersionExclusive)); 67 | 68 | client.readRowsAsync(query, new ResponseObserver<>() { 69 | final List results = new LinkedList<>(); 70 | boolean seenCurrentVersion = false; 71 | 72 | @Override 73 | public void onStart(StreamController controller) {} 74 | 75 | @Override 76 | public void onResponse(Row response) { 77 | try { 78 | GroupChange groupChange = GroupChange.parseFrom(response.getCells(FAMILY, COLUMN_CHANGE).stream().findFirst().orElseThrow().getValue()); 79 | Group groupState = Group.parseFrom(response.getCells(FAMILY, COLUMN_STATE).stream().findFirst().orElseThrow().getValue()); 80 | if (groupState.getVersion() == currentVersion) { 81 | seenCurrentVersion = true; 82 | } 83 | GroupChangeState.Builder groupChangeStateBuilder = GroupChangeState.newBuilder().setGroupChange(groupChange); 84 | if (maxSupportedChangeEpoch == null || maxSupportedChangeEpoch < groupChange.getChangeEpoch() 85 | || (includeFirstState && groupState.getVersion() == fromVersionInclusive) 86 | || (includeLastState && groupState.getVersion() == toVersionExclusive - 1)) { 87 | groupChangeStateBuilder.setGroupState(groupState); 88 | } 89 | results.add(groupChangeStateBuilder.build()); 90 | } catch (InvalidProtocolBufferException e) { 91 | future.completeExceptionally(e); 92 | } 93 | } 94 | 95 | @Override 96 | public void onError(Throwable t) { 97 | timerContext.close(); 98 | future.completeExceptionally(t); 99 | } 100 | 101 | @Override 102 | public void onComplete() { 103 | timerContext.close(); 104 | future.complete(new Pair<>(results, seenCurrentVersion)); 105 | } 106 | }); 107 | 108 | return future; 109 | } 110 | 111 | private ByteString getRowId(ByteString groupId, int version) { 112 | return groupId.concat(ByteString.copyFromUtf8("#")).concat(ByteString.copyFrom(Conversions.intToByteArray(version))); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/util/Conversions.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.util; 7 | 8 | public class Conversions { 9 | 10 | public static byte intsToByteHighAndLow(int highValue, int lowValue) { 11 | return (byte)((highValue << 4 | lowValue) & 0xFF); 12 | } 13 | 14 | public static int highBitsToInt(byte value) { 15 | return (value & 0xFF) >> 4; 16 | } 17 | 18 | public static int lowBitsToInt(byte value) { 19 | return (value & 0xF); 20 | } 21 | 22 | public static int highBitsToMedium(int value) { 23 | return (value >> 12); 24 | } 25 | 26 | public static int lowBitsToMedium(int value) { 27 | return (value & 0xFFF); 28 | } 29 | 30 | public static byte[] shortToByteArray(int value) { 31 | byte[] bytes = new byte[2]; 32 | shortToByteArray(bytes, 0, value); 33 | return bytes; 34 | } 35 | 36 | public static int shortToByteArray(byte[] bytes, int offset, int value) { 37 | bytes[offset+1] = (byte)value; 38 | bytes[offset] = (byte)(value >> 8); 39 | return 2; 40 | } 41 | 42 | public static int shortToLittleEndianByteArray(byte[] bytes, int offset, int value) { 43 | bytes[offset] = (byte)value; 44 | bytes[offset+1] = (byte)(value >> 8); 45 | return 2; 46 | } 47 | 48 | public static byte[] mediumToByteArray(int value) { 49 | byte[] bytes = new byte[3]; 50 | mediumToByteArray(bytes, 0, value); 51 | return bytes; 52 | } 53 | 54 | public static int mediumToByteArray(byte[] bytes, int offset, int value) { 55 | bytes[offset + 2] = (byte)value; 56 | bytes[offset + 1] = (byte)(value >> 8); 57 | bytes[offset] = (byte)(value >> 16); 58 | return 3; 59 | } 60 | 61 | public static byte[] intToByteArray(int value) { 62 | byte[] bytes = new byte[4]; 63 | intToByteArray(bytes, 0, value); 64 | return bytes; 65 | } 66 | 67 | public static int intToByteArray(byte[] bytes, int offset, int value) { 68 | bytes[offset + 3] = (byte)value; 69 | bytes[offset + 2] = (byte)(value >> 8); 70 | bytes[offset + 1] = (byte)(value >> 16); 71 | bytes[offset] = (byte)(value >> 24); 72 | return 4; 73 | } 74 | 75 | public static int intToLittleEndianByteArray(byte[] bytes, int offset, int value) { 76 | bytes[offset] = (byte)value; 77 | bytes[offset+1] = (byte)(value >> 8); 78 | bytes[offset+2] = (byte)(value >> 16); 79 | bytes[offset+3] = (byte)(value >> 24); 80 | return 4; 81 | } 82 | 83 | public static byte[] longToByteArray(long l) { 84 | byte[] bytes = new byte[8]; 85 | longToByteArray(bytes, 0, l); 86 | return bytes; 87 | } 88 | 89 | public static int longToByteArray(byte[] bytes, int offset, long value) { 90 | bytes[offset + 7] = (byte)value; 91 | bytes[offset + 6] = (byte)(value >> 8); 92 | bytes[offset + 5] = (byte)(value >> 16); 93 | bytes[offset + 4] = (byte)(value >> 24); 94 | bytes[offset + 3] = (byte)(value >> 32); 95 | bytes[offset + 2] = (byte)(value >> 40); 96 | bytes[offset + 1] = (byte)(value >> 48); 97 | bytes[offset] = (byte)(value >> 56); 98 | return 8; 99 | } 100 | 101 | public static int longTo4ByteArray(byte[] bytes, int offset, long value) { 102 | bytes[offset + 3] = (byte)value; 103 | bytes[offset + 2] = (byte)(value >> 8); 104 | bytes[offset + 1] = (byte)(value >> 16); 105 | bytes[offset + 0] = (byte)(value >> 24); 106 | return 4; 107 | } 108 | 109 | public static int byteArrayToShort(byte[] bytes) { 110 | return byteArrayToShort(bytes, 0); 111 | } 112 | 113 | public static int byteArrayToShort(byte[] bytes, int offset) { 114 | return 115 | (bytes[offset] & 0xff) << 8 | (bytes[offset + 1] & 0xff); 116 | } 117 | 118 | // The SSL patented 3-byte Value. 119 | public static int byteArrayToMedium(byte[] bytes, int offset) { 120 | return 121 | (bytes[offset] & 0xff) << 16 | 122 | (bytes[offset + 1] & 0xff) << 8 | 123 | (bytes[offset + 2] & 0xff); 124 | } 125 | 126 | public static int byteArrayToInt(byte[] bytes) { 127 | return byteArrayToInt(bytes, 0); 128 | } 129 | 130 | public static int byteArrayToInt(byte[] bytes, int offset) { 131 | return 132 | (bytes[offset] & 0xff) << 24 | 133 | (bytes[offset + 1] & 0xff) << 16 | 134 | (bytes[offset + 2] & 0xff) << 8 | 135 | (bytes[offset + 3] & 0xff); 136 | } 137 | 138 | public static int byteArrayToIntLittleEndian(byte[] bytes, int offset) { 139 | return 140 | (bytes[offset + 3] & 0xff) << 24 | 141 | (bytes[offset + 2] & 0xff) << 16 | 142 | (bytes[offset + 1] & 0xff) << 8 | 143 | (bytes[offset] & 0xff); 144 | } 145 | 146 | public static long byteArrayToLong(byte[] bytes) { 147 | return byteArrayToLong(bytes, 0); 148 | } 149 | 150 | public static long byteArray4ToLong(byte[] bytes, int offset) { 151 | return 152 | ((bytes[offset + 0] & 0xffL) << 24) | 153 | ((bytes[offset + 1] & 0xffL) << 16) | 154 | ((bytes[offset + 2] & 0xffL) << 8) | 155 | ((bytes[offset + 3] & 0xffL)); 156 | } 157 | 158 | public static long byteArrayToLong(byte[] bytes, int offset) { 159 | return 160 | ((bytes[offset] & 0xffL) << 56) | 161 | ((bytes[offset + 1] & 0xffL) << 48) | 162 | ((bytes[offset + 2] & 0xffL) << 40) | 163 | ((bytes[offset + 3] & 0xffL) << 32) | 164 | ((bytes[offset + 4] & 0xffL) << 24) | 165 | ((bytes[offset + 5] & 0xffL) << 16) | 166 | ((bytes[offset + 6] & 0xffL) << 8) | 167 | ((bytes[offset + 7] & 0xffL)); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/StorageManifestsTable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import com.codahale.metrics.MetricRegistry; 9 | import com.codahale.metrics.SharedMetricRegistries; 10 | import com.codahale.metrics.Timer; 11 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 12 | import com.google.cloud.bigtable.data.v2.models.Filters; 13 | import com.google.cloud.bigtable.data.v2.models.Mutation; 14 | import com.google.cloud.bigtable.data.v2.models.Row; 15 | import com.google.cloud.bigtable.data.v2.models.RowCell; 16 | import com.google.cloud.bigtable.data.v2.models.RowMutation; 17 | import com.google.protobuf.ByteString; 18 | import org.signal.storageservice.auth.User; 19 | import org.signal.storageservice.metrics.StorageMetrics; 20 | import org.signal.storageservice.storage.protos.contacts.StorageManifest; 21 | 22 | import java.util.List; 23 | import java.util.Optional; 24 | import java.util.concurrent.CompletableFuture; 25 | 26 | import static com.codahale.metrics.MetricRegistry.name; 27 | 28 | public class StorageManifestsTable extends Table { 29 | 30 | static final String FAMILY = "m"; 31 | 32 | static final String COLUMN_VERSION = "ver"; 33 | static final String COLUMN_DATA = "dat"; 34 | 35 | private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(StorageMetrics.NAME); 36 | private final Timer getTimer = metricRegistry.timer(name(StorageManifestsTable.class, "get" )); 37 | private final Timer setTimer = metricRegistry.timer(name(StorageManifestsTable.class, "create" )); 38 | private final Timer getIfNotVersionTimer = metricRegistry.timer(name(StorageManifestsTable.class, "getIfNotVersion")); 39 | private final Timer deleteManifestTimer = metricRegistry.timer(name(StorageManifestsTable.class, "delete" )); 40 | 41 | public StorageManifestsTable(BigtableDataClient client, String tableId) { 42 | super(client, tableId); 43 | } 44 | 45 | /** 46 | * Updates the {@link StorageManifest} for the given user. The update is applied if and only if no manifest exists for 47 | * the given user or the given {@code manifest}'s version is exactly one greater than the version of the 48 | * existing manifest. 49 | * 50 | * @param user the user for whom to store an updated manifest 51 | * @param manifest the updated manifest to store 52 | * 53 | * @return a future that yields {@code true} if the manifest was updated or {@code false} otherwise 54 | */ 55 | public CompletableFuture set(User user, StorageManifest manifest) { 56 | Mutation updateManifestMutation = Mutation.create() 57 | .setCell(FAMILY, COLUMN_VERSION, 0, String.valueOf(manifest.getVersion())) 58 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_DATA), 0, manifest.getValue()); 59 | 60 | return setIfValueOrEmpty(setTimer, getRowKeyForManifest(user), FAMILY, COLUMN_VERSION, String.valueOf(manifest.getVersion() - 1), updateManifestMutation); 61 | } 62 | 63 | public CompletableFuture> get(User user) { 64 | return toFuture(client.readRowAsync(tableId, getRowKeyForManifest(user)), getTimer).thenApply(this::getManifestFromRow); 65 | } 66 | 67 | public CompletableFuture> getIfNotVersion(User user, long version) { 68 | return toFuture(client.readRowAsync(tableId, getRowKeyForManifest(user), Filters.FILTERS.condition(Filters.FILTERS.chain() 69 | .filter(Filters.FILTERS.key().exactMatch(getRowKeyForManifest(user))) 70 | .filter(Filters.FILTERS.family().exactMatch(FAMILY)) 71 | .filter(Filters.FILTERS.qualifier().exactMatch(COLUMN_VERSION)) 72 | .filter(Filters.FILTERS.value().exactMatch(String.valueOf(version)))) 73 | .then(Filters.FILTERS.block()) 74 | .otherwise(Filters.FILTERS.pass())), 75 | getIfNotVersionTimer).thenApply(this::getManifestFromRow); 76 | } 77 | 78 | public CompletableFuture clear(final User user) { 79 | return toFuture(client.mutateRowAsync(RowMutation.create(tableId, getRowKeyForManifest(user)).deleteRow()), deleteManifestTimer); 80 | } 81 | 82 | private ByteString getRowKeyForManifest(User user) { 83 | return ByteString.copyFromUtf8(user.getUuid().toString() + "#manifest"); 84 | } 85 | 86 | private Optional getManifestFromRow(Row row) { 87 | if (row == null) return Optional.empty(); 88 | 89 | StorageManifest.Builder contactsManifest = StorageManifest.newBuilder(); 90 | List manifestCells = row.getCells(FAMILY); 91 | 92 | contactsManifest.setVersion(Long.valueOf(manifestCells.stream().filter(cell -> COLUMN_VERSION.equals(cell.getQualifier().toStringUtf8())).findFirst().orElseThrow().getValue().toStringUtf8())); 93 | contactsManifest.setValue(manifestCells.stream().filter(cell -> COLUMN_DATA.equals(cell.getQualifier().toStringUtf8())).findFirst().orElseThrow().getValue()); 94 | 95 | return Optional.of(contactsManifest.build()); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/Table.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import com.codahale.metrics.Timer; 9 | import com.google.api.core.ApiFuture; 10 | import com.google.api.core.ApiFutureCallback; 11 | import com.google.api.core.ApiFutures; 12 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 13 | import com.google.cloud.bigtable.data.v2.models.ConditionalRowMutation; 14 | import com.google.cloud.bigtable.data.v2.models.Mutation; 15 | import com.google.cloud.bigtable.data.v2.models.TableId; 16 | import com.google.common.util.concurrent.MoreExecutors; 17 | import com.google.protobuf.ByteString; 18 | 19 | import java.util.concurrent.CompletableFuture; 20 | 21 | import static com.google.cloud.bigtable.data.v2.models.Filters.FILTERS; 22 | 23 | abstract class Table { 24 | 25 | final BigtableDataClient client; 26 | final TableId tableId; 27 | 28 | public Table(final BigtableDataClient client, final String tableId) { 29 | this.client = client; 30 | this.tableId = TableId.of(tableId); 31 | } 32 | 33 | /** 34 | * Applies a mutation to the given row if and only if the cell with the given column family/name has exactly the given 35 | * value or the identified cell is empty. 36 | * 37 | * @param timer a timer to measure the duration of the operation 38 | * @param rowId the ID of the row to potentially mutate 39 | * @param columnFamily the column family of the cell to check for a specific value 40 | * @param columnName the column name of the cell to check for a specific value 41 | * @param columnEquals the value for which to check in the identified cell 42 | * @param mutation the mutation to apply if {@code columnEquals} exactly matches the existing value in the identified 43 | * cell or if the identified cell is empty 44 | * 45 | * @return a future that yields {@code true} if the identified row was modified or {@code false} otherwise 46 | * */ 47 | CompletableFuture setIfValueOrEmpty(Timer timer, ByteString rowId, String columnFamily, String columnName, String columnEquals, Mutation mutation) { 48 | return setIfValue(timer, rowId, columnFamily, columnName, columnEquals, mutation) 49 | .thenCompose(mutated -> mutated 50 | ? CompletableFuture.completedFuture(true) 51 | : setIfEmpty(timer, rowId, columnFamily, columnName, mutation)); 52 | } 53 | 54 | /** 55 | * Applies a mutation to the given row if and only if the cell with the given column family/name has exactly the given 56 | * value. 57 | * 58 | * @param timer a timer to measure the duration of the operation 59 | * @param rowId the ID of the row to potentially mutate 60 | * @param columnFamily the column family of the cell to check for a specific value 61 | * @param columnName the column name of the cell to check for a specific value 62 | * @param columnEquals the value for which to check in the identified cell 63 | * @param mutation the mutation to apply if {@code columnEquals} exactly matches the existing value in the identified 64 | * cell 65 | * 66 | * @return a future that yields {@code true} if the identified row was modified or {@code false} otherwise 67 | */ 68 | CompletableFuture setIfValue(Timer timer, ByteString rowId, String columnFamily, String columnName, String columnEquals, Mutation mutation) { 69 | return toFuture(client.checkAndMutateRowAsync(ConditionalRowMutation.create(tableId, rowId) 70 | .condition(FILTERS.chain() 71 | .filter(FILTERS.family().exactMatch(columnFamily)) 72 | .filter(FILTERS.qualifier().exactMatch(columnName)) 73 | .filter(FILTERS.value().exactMatch(columnEquals))) 74 | .then(mutation)), timer); 75 | } 76 | 77 | /** 78 | * Applies a mutation to the given row if and only if the cell with the given column family/name is empty. 79 | * 80 | * @param timer a timer to measure the duration of the operation 81 | * @param rowId the ID of the row to potentially mutate 82 | * @param columnFamily the column family of the cell to check for a specific value 83 | * @param columnName the column name of the cell to check for a specific value 84 | * @param mutation the mutation to apply if the identified cell is empty 85 | * 86 | * @return a future that yields {@code true} if the identified row was modified or {@code false} otherwise 87 | */ 88 | CompletableFuture setIfEmpty(Timer timer, ByteString rowId, String columnFamily, String columnName, Mutation mutation) { 89 | return toFuture(client.checkAndMutateRowAsync(ConditionalRowMutation.create(tableId, rowId) 90 | .condition(FILTERS.chain() 91 | .filter(FILTERS.family().exactMatch(columnFamily)) 92 | .filter(FILTERS.qualifier().exactMatch(columnName)) 93 | // See https://github.com/google/re2/wiki/Syntax; `\C` is "any byte", and so this matches any 94 | // non-empty value. Note that the mutation is applied in an `otherwise` clause. 95 | .filter(FILTERS.value().regex("\\C+"))) 96 | .otherwise(mutation)), timer) 97 | // Note that we apply the mutation only if the predicate does NOT match, and so we invert `predicateMatched` to 98 | // indicate that we have (or haven't) mutated the row 99 | .thenApply(predicateMatched -> !predicateMatched); 100 | } 101 | 102 | 103 | static CompletableFuture toFuture(ApiFuture future, Timer timer) { 104 | Timer.Context timerContext = timer.time(); 105 | CompletableFuture result = new CompletableFuture<>(); 106 | 107 | ApiFutures.addCallback(future, new ApiFutureCallback() { 108 | @Override 109 | public void onFailure(Throwable t) { 110 | timerContext.close(); 111 | result.completeExceptionally(t); 112 | } 113 | 114 | @Override 115 | public void onSuccess(T t) { 116 | timerContext.close(); 117 | result.complete(t); 118 | } 119 | }, MoreExecutors.directExecutor()); 120 | 121 | return result; 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/providers/ProtocolBufferMessageBodyProvider.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2019 Smoke Turner, LLC (github@smoketurner.com) 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package org.signal.storageservice.providers; 17 | 18 | import com.google.protobuf.InvalidProtocolBufferException; 19 | import com.google.protobuf.Message; 20 | import com.google.protobuf.TextFormat; 21 | import com.google.protobuf.util.JsonFormat; 22 | 23 | import jakarta.ws.rs.Consumes; 24 | import jakarta.ws.rs.Produces; 25 | import jakarta.ws.rs.WebApplicationException; 26 | import jakarta.ws.rs.core.MediaType; 27 | import jakarta.ws.rs.core.MultivaluedMap; 28 | import jakarta.ws.rs.ext.MessageBodyReader; 29 | import jakarta.ws.rs.ext.MessageBodyWriter; 30 | import jakarta.ws.rs.ext.Provider; 31 | import java.io.IOException; 32 | import java.io.InputStream; 33 | import java.io.InputStreamReader; 34 | import java.io.OutputStream; 35 | import java.lang.annotation.Annotation; 36 | import java.lang.reflect.Method; 37 | import java.lang.reflect.Type; 38 | import java.nio.charset.StandardCharsets; 39 | import java.util.Map; 40 | import java.util.concurrent.ConcurrentHashMap; 41 | 42 | /** 43 | * A Jersey provider which enables using Protocol Buffers to parse request entities into objects and 44 | * generate response entities from objects. 45 | */ 46 | @Provider 47 | @Consumes({ 48 | ProtocolBufferMediaType.APPLICATION_PROTOBUF, 49 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_TEXT, 50 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_JSON 51 | }) 52 | @Produces({ 53 | ProtocolBufferMediaType.APPLICATION_PROTOBUF, 54 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_TEXT, 55 | ProtocolBufferMediaType.APPLICATION_PROTOBUF_JSON 56 | }) 57 | public class ProtocolBufferMessageBodyProvider 58 | implements MessageBodyReader, MessageBodyWriter { 59 | 60 | private final Map, Method> methodCache = new ConcurrentHashMap<>(); 61 | 62 | @Override 63 | public boolean isReadable( 64 | final Class type, 65 | final Type genericType, 66 | final Annotation[] annotations, 67 | final MediaType mediaType) { 68 | return Message.class.isAssignableFrom(type); 69 | } 70 | 71 | @Override 72 | public Message readFrom( 73 | final Class type, 74 | final Type genericType, 75 | final Annotation[] annotations, 76 | final MediaType mediaType, 77 | final MultivaluedMap httpHeaders, 78 | final InputStream entityStream) 79 | throws IOException { 80 | 81 | final Method newBuilder = 82 | methodCache.computeIfAbsent( 83 | type, 84 | t -> { 85 | try { 86 | return t.getMethod("newBuilder"); 87 | } catch (Exception e) { 88 | return null; 89 | } 90 | }); 91 | 92 | final Message.Builder builder; 93 | try { 94 | builder = (Message.Builder) newBuilder.invoke(type); 95 | } catch (Exception e) { 96 | throw new WebApplicationException(e); 97 | } 98 | 99 | if (mediaType.getSubtype().contains("text-format")) { 100 | TextFormat.merge(new InputStreamReader(entityStream, StandardCharsets.UTF_8), builder); 101 | return builder.build(); 102 | } else if (mediaType.getSubtype().contains("json-format")) { 103 | JsonFormat.parser() 104 | .merge(new InputStreamReader(entityStream, StandardCharsets.UTF_8), builder); 105 | return builder.build(); 106 | } else { 107 | return builder.mergeFrom(entityStream).build(); 108 | } 109 | } 110 | 111 | @Override 112 | public long getSize( 113 | final Message m, 114 | final Class type, 115 | final Type genericType, 116 | final Annotation[] annotations, 117 | final MediaType mediaType) { 118 | 119 | if (mediaType.getSubtype().contains("text-format")) { 120 | final String formatted = TextFormat.printer().escapingNonAscii(false).printToString(m); 121 | return formatted.getBytes(StandardCharsets.UTF_8).length; 122 | } else if (mediaType.getSubtype().contains("json-format")) { 123 | try { 124 | final String formatted = JsonFormat.printer().omittingInsignificantWhitespace().print(m); 125 | return formatted.getBytes(StandardCharsets.UTF_8).length; 126 | } catch (InvalidProtocolBufferException e) { 127 | // invalid protocol message 128 | return -1L; 129 | } 130 | } 131 | 132 | return m.getSerializedSize(); 133 | } 134 | 135 | @Override 136 | public boolean isWriteable( 137 | final Class type, 138 | final Type genericType, 139 | final Annotation[] annotations, 140 | final MediaType mediaType) { 141 | return Message.class.isAssignableFrom(type); 142 | } 143 | 144 | @Override 145 | public void writeTo( 146 | final Message m, 147 | final Class type, 148 | final Type genericType, 149 | final Annotation[] annotations, 150 | final MediaType mediaType, 151 | final MultivaluedMap httpHeaders, 152 | final OutputStream entityStream) 153 | throws IOException { 154 | 155 | if (mediaType.getSubtype().contains("text-format")) { 156 | entityStream.write(m.toString().getBytes(StandardCharsets.UTF_8)); 157 | } else if (mediaType.getSubtype().contains("json-format")) { 158 | final String formatted = JsonFormat.printer().omittingInsignificantWhitespace().print(m); 159 | entityStream.write(formatted.getBytes(StandardCharsets.UTF_8)); 160 | } else { 161 | m.writeTo(entityStream); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/metrics/MetricsHttpChannelListener.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.metrics; 7 | 8 | import com.google.common.annotations.VisibleForTesting; 9 | import com.google.common.net.HttpHeaders; 10 | import io.dropwizard.core.setup.Environment; 11 | import io.micrometer.core.instrument.MeterRegistry; 12 | import io.micrometer.core.instrument.Metrics; 13 | import io.micrometer.core.instrument.Tags; 14 | import jakarta.ws.rs.container.ContainerRequestContext; 15 | import jakarta.ws.rs.container.ContainerResponseContext; 16 | import jakarta.ws.rs.container.ContainerResponseFilter; 17 | import java.io.IOException; 18 | import java.util.Optional; 19 | import javax.annotation.Nullable; 20 | import org.eclipse.jetty.server.Connector; 21 | import org.eclipse.jetty.server.HttpChannel; 22 | import org.eclipse.jetty.server.Request; 23 | import org.eclipse.jetty.util.component.Container; 24 | import org.eclipse.jetty.util.component.LifeCycle; 25 | import org.glassfish.jersey.server.ExtendedUriInfo; 26 | import org.signal.storageservice.util.UriInfoUtil; 27 | import org.slf4j.Logger; 28 | import org.slf4j.LoggerFactory; 29 | 30 | /** 31 | * Gathers and reports HTTP request metrics at the Jetty container level, which sits above Jersey. In order to get 32 | * templated Jersey request paths, it implements {@link ContainerResponseFilter}, in order to give itself access to the 33 | * template. 34 | *

35 | * It implements {@link LifeCycle.Listener} without overriding methods, so that it can be an event listener that 36 | * Dropwizard will attach to the container—the {@link Container.Listener} implementation is where it attaches 37 | * itself to any {@link Connector}s. 38 | */ 39 | public class MetricsHttpChannelListener implements HttpChannel.Listener, Container.Listener, LifeCycle.Listener, 40 | ContainerResponseFilter { 41 | 42 | private static final Logger logger = LoggerFactory.getLogger(MetricsHttpChannelListener.class); 43 | 44 | private record RequestInfo(String path, String method, int statusCode, @Nullable String userAgent) { 45 | } 46 | 47 | // Use the same counter namespace as the now-retired MetricsRequestEventListener for continuity 48 | @VisibleForTesting 49 | static final String REQUEST_COUNTER_NAME = 50 | "org.signal.storageservice.metrics.MetricsRequestEventListener.request"; 51 | 52 | @VisibleForTesting 53 | static final String REQUEST_BYTES_COUNTER_NAME = 54 | MetricsUtil.name(MetricsHttpChannelListener.class, "requestBytes"); 55 | 56 | @VisibleForTesting 57 | static final String RESPONSE_BYTES_COUNTER_NAME = 58 | MetricsUtil.name(MetricsHttpChannelListener.class, "responseBytes"); 59 | 60 | @VisibleForTesting 61 | static final String URI_INFO_PROPERTY_NAME = MetricsHttpChannelListener.class.getName() + ".uriInfo"; 62 | 63 | @VisibleForTesting 64 | static final String PATH_TAG = "path"; 65 | 66 | @VisibleForTesting 67 | static final String METHOD_TAG = "method"; 68 | 69 | @VisibleForTesting 70 | static final String STATUS_CODE_TAG = "status"; 71 | 72 | private final MeterRegistry meterRegistry; 73 | 74 | public MetricsHttpChannelListener() { 75 | this(Metrics.globalRegistry); 76 | } 77 | 78 | @VisibleForTesting 79 | MetricsHttpChannelListener(final MeterRegistry meterRegistry) { 80 | this.meterRegistry = meterRegistry; 81 | } 82 | 83 | public void configure(final Environment environment) { 84 | // register as ContainerResponseFilter 85 | environment.jersey().register(this); 86 | 87 | // hook into lifecycle events, to react to the Connector being added 88 | environment.lifecycle().addEventListener(this); 89 | } 90 | 91 | @Override 92 | public void onRequestFailure(final Request request, final Throwable failure) { 93 | 94 | if (logger.isDebugEnabled()) { 95 | final RequestInfo requestInfo = getRequestInfo(request); 96 | 97 | logger.debug("Request failure: {} {} ({}) [{}] ", 98 | requestInfo.method(), 99 | requestInfo.path(), 100 | requestInfo.userAgent(), 101 | requestInfo.statusCode(), failure); 102 | } 103 | } 104 | 105 | @Override 106 | public void onResponseFailure(Request request, Throwable failure) { 107 | 108 | if (failure instanceof org.eclipse.jetty.io.EofException) { 109 | // the client disconnected early 110 | return; 111 | } 112 | 113 | final RequestInfo requestInfo = getRequestInfo(request); 114 | 115 | logger.warn("Response failure: {} {} ({}) [{}] ", 116 | requestInfo.method(), 117 | requestInfo.path(), 118 | requestInfo.userAgent(), 119 | requestInfo.statusCode(), failure); 120 | } 121 | 122 | @Override 123 | public void onComplete(final Request request) { 124 | 125 | final RequestInfo requestInfo = getRequestInfo(request); 126 | 127 | final Tags tags = Tags.of( 128 | PATH_TAG, requestInfo.path(), 129 | METHOD_TAG, requestInfo.method(), 130 | STATUS_CODE_TAG, String.valueOf(requestInfo.statusCode())) 131 | .and(UserAgentTagUtil.getPlatformTag(requestInfo.userAgent())); 132 | 133 | meterRegistry.counter(REQUEST_COUNTER_NAME, tags).increment(); 134 | meterRegistry.counter(REQUEST_BYTES_COUNTER_NAME, tags).increment(request.getContentRead()); 135 | meterRegistry.counter(RESPONSE_BYTES_COUNTER_NAME, tags).increment(request.getResponse().getContentCount()); 136 | } 137 | 138 | @Override 139 | public void beanAdded(final Container parent, final Object child) { 140 | if (child instanceof Connector connector) { 141 | connector.addBean(this); 142 | } 143 | } 144 | 145 | @Override 146 | public void beanRemoved(final Container parent, final Object child) { 147 | } 148 | 149 | @Override 150 | public void filter(final ContainerRequestContext requestContext, final ContainerResponseContext responseContext) 151 | throws IOException { 152 | requestContext.setProperty(URI_INFO_PROPERTY_NAME, requestContext.getUriInfo()); 153 | } 154 | 155 | private RequestInfo getRequestInfo(Request request) { 156 | final String path = Optional.ofNullable(request.getAttribute(URI_INFO_PROPERTY_NAME)) 157 | .map(attr -> UriInfoUtil.getPathTemplate((ExtendedUriInfo) attr)) 158 | .orElseGet(() -> Optional.ofNullable(request.getPathInfo()).orElse("unknown")); 159 | 160 | final String method = Optional.ofNullable(request.getMethod()).orElse("unknown"); 161 | 162 | // Response cannot be null, but its status might not always reflect an actual response status, since it gets 163 | // initialized to 200 164 | final int status = request.getResponse().getStatus(); 165 | 166 | @Nullable final String userAgent = request.getHeader(HttpHeaders.USER_AGENT); 167 | 168 | return new RequestInfo(path, method, status, userAgent); 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/storage/StorageItemsTable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.storage; 7 | 8 | import static com.codahale.metrics.MetricRegistry.name; 9 | 10 | import com.codahale.metrics.MetricRegistry; 11 | import com.codahale.metrics.SharedMetricRegistries; 12 | import com.codahale.metrics.Timer; 13 | import com.google.api.gax.rpc.ResponseObserver; 14 | import com.google.api.gax.rpc.StreamController; 15 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 16 | import com.google.cloud.bigtable.data.v2.models.BulkMutation; 17 | import com.google.cloud.bigtable.data.v2.models.Mutation; 18 | import com.google.cloud.bigtable.data.v2.models.Query; 19 | import com.google.cloud.bigtable.data.v2.models.Row; 20 | import com.google.protobuf.ByteString; 21 | import java.util.ArrayList; 22 | import java.util.LinkedList; 23 | import java.util.List; 24 | import java.util.concurrent.CompletableFuture; 25 | import org.apache.commons.codec.binary.Hex; 26 | import org.signal.storageservice.auth.User; 27 | import org.signal.storageservice.metrics.StorageMetrics; 28 | import org.signal.storageservice.storage.protos.contacts.StorageItem; 29 | 30 | public class StorageItemsTable extends Table { 31 | 32 | public static final String FAMILY = "c"; 33 | public static final String ROW_KEY = "contact"; 34 | 35 | public static final String COLUMN_DATA = "d"; 36 | public static final String COLUMN_KEY = "k"; 37 | 38 | public static final int MAX_MUTATIONS = 100_000; 39 | public static final int MUTATIONS_PER_INSERT = 2; 40 | 41 | private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(StorageMetrics.NAME); 42 | private final Timer getTimer = metricRegistry.timer(name(StorageItemsTable.class, "get")); 43 | private final Timer setTimer = metricRegistry.timer(name(StorageItemsTable.class, "create")); 44 | private final Timer getKeysToDeleteTimer = metricRegistry.timer(name(StorageItemsTable.class, "getKeysToDelete")); 45 | private final Timer deleteKeysTimer = metricRegistry.timer(name(StorageItemsTable.class, "deleteKeys")); 46 | 47 | public StorageItemsTable(BigtableDataClient client, String tableId) { 48 | super(client, tableId); 49 | } 50 | 51 | public CompletableFuture set(final User user, final List inserts, final List deletes) { 52 | final List bulkMutations = new ArrayList<>(); 53 | bulkMutations.add(BulkMutation.create(tableId)); 54 | 55 | int mutations = 0; 56 | 57 | for (final StorageItem insert : inserts) { 58 | if (mutations + 2 > MAX_MUTATIONS) { 59 | bulkMutations.add(BulkMutation.create(tableId)); 60 | mutations = 0; 61 | } 62 | 63 | bulkMutations.getLast().add(getRowKeyFor(user, insert.getKey()), 64 | Mutation.create() 65 | // each setCell() counts as mutation. If the below code changes, update MUTATIONS_PER_INSERT 66 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_DATA), 0, insert.getValue()) 67 | .setCell(FAMILY, ByteString.copyFromUtf8(COLUMN_KEY), 0, insert.getKey())); 68 | 69 | mutations += 2; 70 | } 71 | 72 | for (ByteString delete : deletes) { 73 | if (mutations == MAX_MUTATIONS) { 74 | bulkMutations.add(BulkMutation.create(tableId)); 75 | mutations = 0; 76 | } 77 | 78 | bulkMutations.getLast().add(getRowKeyFor(user, delete), Mutation.create().deleteRow()); 79 | 80 | mutations += 1; 81 | } 82 | 83 | CompletableFuture future = CompletableFuture.completedFuture(null); 84 | 85 | for (final BulkMutation bulkMutation : bulkMutations) { 86 | future = future.thenCompose(ignored -> toFuture(client.bulkMutateRowsAsync(bulkMutation), setTimer)); 87 | } 88 | 89 | return future; 90 | } 91 | 92 | public CompletableFuture clear(User user) { 93 | final Query query = Query.create(tableId); 94 | query.prefix(getRowKeyPrefixFor(user)); 95 | query.limit(MAX_MUTATIONS); 96 | 97 | final CompletableFuture fetchRowsFuture = new CompletableFuture<>(); 98 | 99 | final Timer.Context getKeysToDeleteTimerContext = getKeysToDeleteTimer.time(); 100 | fetchRowsFuture.whenComplete((result, throwable) -> getKeysToDeleteTimerContext.close()); 101 | 102 | client.readRowsAsync(query, new ResponseObserver<>() { 103 | private final BulkMutation bulkMutation = BulkMutation.create(tableId); 104 | 105 | @Override 106 | public void onStart(final StreamController streamController) { 107 | } 108 | 109 | @Override 110 | public void onResponse(final Row row) { 111 | bulkMutation.add(row.getKey(), Mutation.create().deleteRow()); 112 | } 113 | 114 | @Override 115 | public void onError(final Throwable throwable) { 116 | fetchRowsFuture.completeExceptionally(throwable); 117 | } 118 | 119 | @Override 120 | public void onComplete() { 121 | fetchRowsFuture.complete(bulkMutation); 122 | } 123 | }); 124 | 125 | return fetchRowsFuture.thenCompose(bulkMutation -> bulkMutation.getEntryCount() == 0 126 | ? CompletableFuture.completedFuture(null) 127 | : toFuture(client.bulkMutateRowsAsync(bulkMutation), deleteKeysTimer).thenCompose(ignored -> clear(user))); 128 | } 129 | 130 | public CompletableFuture> get(User user, List keys) { 131 | if (keys.isEmpty()) { 132 | throw new IllegalArgumentException("No keys"); 133 | } 134 | 135 | Timer.Context timerContext = getTimer.time(); 136 | CompletableFuture> future = new CompletableFuture<>(); 137 | List results = new LinkedList<>(); 138 | Query query = Query.create(tableId); 139 | 140 | for (ByteString key : keys) { 141 | query.rowKey(getRowKeyFor(user, key)); 142 | } 143 | 144 | client.readRowsAsync(query, new ResponseObserver<>() { 145 | @Override 146 | public void onStart(StreamController controller) { 147 | } 148 | 149 | @Override 150 | public void onResponse(Row row) { 151 | ByteString key = row.getCells().stream().filter(cell -> COLUMN_KEY.equals(cell.getQualifier().toStringUtf8())) 152 | .findFirst().orElseThrow().getValue(); 153 | ByteString value = row.getCells().stream() 154 | .filter(cell -> COLUMN_DATA.equals(cell.getQualifier().toStringUtf8())).findFirst().orElseThrow() 155 | .getValue(); 156 | 157 | results.add(StorageItem.newBuilder() 158 | .setKey(key) 159 | .setValue(value) 160 | .build()); 161 | } 162 | 163 | @Override 164 | public void onError(Throwable t) { 165 | timerContext.close(); 166 | future.completeExceptionally(t); 167 | } 168 | 169 | @Override 170 | public void onComplete() { 171 | timerContext.close(); 172 | future.complete(results); 173 | } 174 | }); 175 | 176 | return future; 177 | } 178 | 179 | private ByteString getRowKeyFor(User user, ByteString key) { 180 | return ByteString.copyFromUtf8( 181 | user.getUuid().toString() + "#" + ROW_KEY + "#" + Hex.encodeHexString(key.toByteArray())); 182 | } 183 | 184 | private ByteString getRowKeyPrefixFor(User user) { 185 | return ByteString.copyFromUtf8(user.getUuid().toString() + "#" + ROW_KEY + "#"); 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/controllers/StorageController.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice.controllers; 7 | 8 | import static com.codahale.metrics.MetricRegistry.name; 9 | 10 | import com.codahale.metrics.annotation.Timed; 11 | import com.google.common.annotations.VisibleForTesting; 12 | import io.dropwizard.auth.Auth; 13 | import io.micrometer.core.instrument.DistributionSummary; 14 | import io.micrometer.core.instrument.Metrics; 15 | import io.micrometer.core.instrument.Tags; 16 | import java.time.Duration; 17 | import java.util.concurrent.CompletableFuture; 18 | import jakarta.ws.rs.Consumes; 19 | import jakarta.ws.rs.DELETE; 20 | import jakarta.ws.rs.GET; 21 | import jakarta.ws.rs.HeaderParam; 22 | import jakarta.ws.rs.PUT; 23 | import jakarta.ws.rs.Path; 24 | import jakarta.ws.rs.PathParam; 25 | import jakarta.ws.rs.Produces; 26 | import jakarta.ws.rs.WebApplicationException; 27 | import jakarta.ws.rs.core.HttpHeaders; 28 | import jakarta.ws.rs.core.Response; 29 | import jakarta.ws.rs.core.Response.Status; 30 | import org.signal.storageservice.auth.User; 31 | import org.signal.storageservice.metrics.UserAgentTagUtil; 32 | import org.signal.storageservice.providers.ProtocolBufferMediaType; 33 | import org.signal.storageservice.storage.StorageItemsTable; 34 | import org.signal.storageservice.storage.StorageManager; 35 | import org.signal.storageservice.storage.protos.contacts.ReadOperation; 36 | import org.signal.storageservice.storage.protos.contacts.StorageItems; 37 | import org.signal.storageservice.storage.protos.contacts.StorageManifest; 38 | import org.signal.storageservice.storage.protos.contacts.WriteOperation; 39 | 40 | @Path("/v1/storage") 41 | public class StorageController { 42 | 43 | private final StorageManager storageManager; 44 | 45 | @VisibleForTesting 46 | static final int MAX_READ_KEYS = 5120; 47 | // https://cloud.google.com/bigtable/quotas#limits-operations 48 | 49 | @VisibleForTesting 50 | static final int MAX_BULK_MUTATION_PAGES = 10; 51 | 52 | private static final String INSERT_DISTRIBUTION_SUMMARY_NAME = name(StorageController.class, "inserts"); 53 | private static final String DELETE_DISTRIBUTION_SUMMARY_NAME = name(StorageController.class, "deletes"); 54 | private static final String MUTATION_DISTRIBUTION_SUMMARY_NAME = name(StorageController.class, "mutations"); 55 | private static final String READ_DISTRIBUTION_SUMMARY_NAME = name(StorageController.class, "reads"); 56 | private static final String WRITE_REQUEST_SIZE_DISTRIBUTION_SUMMARY_NAME = name(StorageController.class, "writeRequestBytes"); 57 | 58 | private static final String CLEAR_ALL_REQUEST_COUNTER_NAME = name(StorageController.class, "writeRequestClearAll"); 59 | 60 | public StorageController(StorageManager storageManager) { 61 | this.storageManager = storageManager; 62 | } 63 | 64 | @Timed 65 | @GET 66 | @Path("/manifest") 67 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 68 | public CompletableFuture getManifest(@Auth User user) { 69 | return storageManager.getManifest(user) 70 | .thenApply(manifest -> manifest.orElseThrow(() -> new WebApplicationException(Response.Status.NOT_FOUND))); 71 | } 72 | 73 | @Timed 74 | @GET 75 | @Path("/manifest/version/{version}") 76 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 77 | public CompletableFuture getManifest(@Auth User user, @PathParam("version") long version) { 78 | return storageManager.getManifestIfNotVersion(user, version) 79 | .thenApply(manifest -> manifest.orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT))); 80 | } 81 | 82 | 83 | @Timed 84 | @PUT 85 | @Consumes(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 86 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 87 | public CompletableFuture write(@Auth User user, @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, WriteOperation writeOperation) { 88 | if (!writeOperation.hasManifest()) { 89 | return CompletableFuture.failedFuture(new WebApplicationException(Response.Status.BAD_REQUEST)); 90 | } 91 | 92 | distributionSummary(INSERT_DISTRIBUTION_SUMMARY_NAME, userAgent).record(writeOperation.getInsertItemCount()); 93 | distributionSummary(DELETE_DISTRIBUTION_SUMMARY_NAME, userAgent).record(writeOperation.getDeleteKeyCount()); 94 | distributionSummary(WRITE_REQUEST_SIZE_DISTRIBUTION_SUMMARY_NAME, userAgent).record(writeOperation.getSerializedSize()); 95 | 96 | if (writeOperation.getClearAll()) { 97 | Metrics.counter(CLEAR_ALL_REQUEST_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); 98 | } 99 | 100 | final int mutations = 101 | writeOperation.getInsertItemCount() * StorageItemsTable.MUTATIONS_PER_INSERT + writeOperation.getDeleteKeyCount(); 102 | 103 | distributionSummary(MUTATION_DISTRIBUTION_SUMMARY_NAME, userAgent).record(mutations); 104 | 105 | if (mutations > StorageItemsTable.MAX_MUTATIONS * MAX_BULK_MUTATION_PAGES) { 106 | return CompletableFuture.failedFuture(new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE)); 107 | } 108 | 109 | final CompletableFuture clearAllFuture = writeOperation.getClearAll() 110 | ? storageManager.clearItems(user) 111 | : CompletableFuture.completedFuture(null); 112 | 113 | return clearAllFuture.thenCompose(ignored -> storageManager.set(user, writeOperation.getManifest(), writeOperation.getInsertItemList(), writeOperation.getDeleteKeyList())) 114 | .thenApply(manifest -> { 115 | if (manifest.isPresent()) 116 | return Response.status(409).entity(manifest.get()).build(); 117 | else return Response.status(200).build(); 118 | }); 119 | } 120 | 121 | private static DistributionSummary distributionSummary(final String name, final String userAgent) { 122 | return DistributionSummary.builder(name) 123 | .publishPercentiles(0.75, 0.95, 0.99, 0.999) 124 | .distributionStatisticExpiry(Duration.ofMinutes(5)) 125 | .tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) 126 | .register(Metrics.globalRegistry); 127 | } 128 | 129 | @Timed 130 | @PUT 131 | @Path("/read") 132 | @Consumes(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 133 | @Produces(ProtocolBufferMediaType.APPLICATION_PROTOBUF) 134 | public CompletableFuture read(@Auth User user, @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, ReadOperation readOperation) { 135 | if (readOperation.getReadKeyList().isEmpty()) { 136 | return CompletableFuture.failedFuture(new WebApplicationException(Response.Status.BAD_REQUEST)); 137 | } 138 | 139 | distributionSummary(READ_DISTRIBUTION_SUMMARY_NAME, userAgent).record(readOperation.getReadKeyCount()); 140 | 141 | if (readOperation.getReadKeyCount() > MAX_READ_KEYS) { 142 | return CompletableFuture.failedFuture(new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE)); 143 | } 144 | 145 | return storageManager.getItems(user, readOperation.getReadKeyList()) 146 | .thenApply(items -> StorageItems.newBuilder().addAllContacts(items).build()); 147 | } 148 | 149 | @Timed 150 | @DELETE 151 | public CompletableFuture delete(@Auth User user) { 152 | return storageManager.delete(user).thenApply(v -> Response.status(Response.Status.OK).build()); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/main/java/org/signal/storageservice/StorageService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2021 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | package org.signal.storageservice; 7 | 8 | import com.fasterxml.jackson.annotation.JsonAutoDetect; 9 | import com.fasterxml.jackson.annotation.PropertyAccessor; 10 | import com.fasterxml.jackson.databind.DeserializationFeature; 11 | import com.google.cloud.bigtable.data.v2.BigtableDataClient; 12 | import com.google.cloud.bigtable.data.v2.BigtableDataSettings; 13 | import com.google.common.collect.ImmutableMap; 14 | import com.google.common.collect.ImmutableSet; 15 | import io.dropwizard.auth.AuthFilter; 16 | import io.dropwizard.auth.PolymorphicAuthDynamicFeature; 17 | import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; 18 | import io.dropwizard.auth.basic.BasicCredentialAuthFilter; 19 | import io.dropwizard.auth.basic.BasicCredentials; 20 | import io.dropwizard.core.Application; 21 | import io.dropwizard.core.setup.Bootstrap; 22 | import io.dropwizard.core.setup.Environment; 23 | import java.time.Clock; 24 | import java.util.Set; 25 | import org.signal.libsignal.zkgroup.ServerSecretParams; 26 | import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; 27 | import org.signal.storageservice.auth.ExternalGroupCredentialGenerator; 28 | import org.signal.storageservice.auth.ExternalServiceCredentialValidator; 29 | import org.signal.storageservice.auth.GroupUser; 30 | import org.signal.storageservice.auth.GroupUserAuthenticator; 31 | import org.signal.storageservice.auth.User; 32 | import org.signal.storageservice.auth.UserAuthenticator; 33 | import org.signal.storageservice.controllers.GroupsController; 34 | import org.signal.storageservice.controllers.GroupsV1Controller; 35 | import org.signal.storageservice.controllers.HealthCheckController; 36 | import org.signal.storageservice.controllers.ReadinessController; 37 | import org.signal.storageservice.controllers.StorageController; 38 | import org.signal.storageservice.filters.TimestampResponseFilter; 39 | import org.signal.storageservice.metrics.MetricsHttpChannelListener; 40 | import org.signal.storageservice.metrics.MetricsUtil; 41 | import org.signal.storageservice.providers.CompletionExceptionMapper; 42 | import org.signal.storageservice.providers.InvalidProtocolBufferExceptionMapper; 43 | import org.signal.storageservice.providers.ProtocolBufferMessageBodyProvider; 44 | import org.signal.storageservice.providers.ProtocolBufferValidationErrorMessageBodyWriter; 45 | import org.signal.storageservice.s3.PolicySigner; 46 | import org.signal.storageservice.s3.PostPolicyGenerator; 47 | import org.signal.storageservice.storage.GroupsManager; 48 | import org.signal.storageservice.storage.StorageManager; 49 | import org.signal.storageservice.util.UncaughtExceptionHandler; 50 | import org.signal.storageservice.util.logging.LoggingUnhandledExceptionMapper; 51 | 52 | public class StorageService extends Application { 53 | 54 | @Override 55 | public void initialize(Bootstrap bootstrap) { } 56 | 57 | @Override 58 | public void run(StorageServiceConfiguration config, Environment environment) throws Exception { 59 | MetricsUtil.configureRegistries(config, environment); 60 | 61 | UncaughtExceptionHandler.register(); 62 | 63 | BigtableDataSettings bigtableDataSettings = BigtableDataSettings.newBuilder() 64 | .setProjectId(config.getBigTableConfiguration().getProjectId()) 65 | .setInstanceId(config.getBigTableConfiguration().getInstanceId()) 66 | .build(); 67 | BigtableDataClient bigtableDataClient = BigtableDataClient.create(bigtableDataSettings); 68 | ServerSecretParams serverSecretParams = new ServerSecretParams(config.getZkConfiguration().getServerSecret()); 69 | StorageManager storageManager = new StorageManager(bigtableDataClient, config.getBigTableConfiguration().getContactManifestsTableId(), config.getBigTableConfiguration().getContactsTableId()); 70 | GroupsManager groupsManager = new GroupsManager(bigtableDataClient, config.getBigTableConfiguration().getGroupsTableId(), config.getBigTableConfiguration().getGroupLogsTableId()); 71 | 72 | environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 73 | environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); 74 | environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); 75 | 76 | environment.jersey().register(ProtocolBufferMessageBodyProvider.class); 77 | environment.jersey().register(ProtocolBufferValidationErrorMessageBodyWriter.class); 78 | environment.jersey().register(InvalidProtocolBufferExceptionMapper.class); 79 | environment.jersey().register(CompletionExceptionMapper.class); 80 | environment.jersey().register(new LoggingUnhandledExceptionMapper()); 81 | 82 | UserAuthenticator userAuthenticator = new UserAuthenticator(new ExternalServiceCredentialValidator(config.getAuthenticationConfiguration().getKey())); 83 | GroupUserAuthenticator groupUserAuthenticator = new GroupUserAuthenticator(new ServerZkAuthOperations(serverSecretParams)); 84 | ExternalGroupCredentialGenerator externalGroupCredentialGenerator = new ExternalGroupCredentialGenerator( 85 | config.getGroupConfiguration().externalServiceSecret(), Clock.systemUTC()); 86 | 87 | AuthFilter userAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(userAuthenticator).buildAuthFilter(); 88 | AuthFilter groupUserAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(groupUserAuthenticator).buildAuthFilter(); 89 | 90 | PolicySigner policySigner = new PolicySigner(config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion()); 91 | PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket(), config.getCdnConfiguration().getAccessKey()); 92 | 93 | environment.jersey().register(new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(User.class, userAuthFilter, GroupUser.class, groupUserAuthFilter))); 94 | environment.jersey().register(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(User.class, GroupUser.class))); 95 | 96 | environment.jersey().register(new TimestampResponseFilter(Clock.systemUTC())); 97 | 98 | environment.jersey().register(new HealthCheckController()); 99 | environment.jersey().register(new ReadinessController(bigtableDataClient, 100 | Set.of(config.getBigTableConfiguration().getGroupsTableId(), 101 | config.getBigTableConfiguration().getGroupLogsTableId(), 102 | config.getBigTableConfiguration().getContactsTableId(), 103 | config.getBigTableConfiguration().getContactManifestsTableId()), 104 | config.getWarmUpConfiguration().count())); 105 | environment.jersey().register(new StorageController(storageManager)); 106 | environment.jersey().register(new GroupsController(Clock.systemUTC(), groupsManager, serverSecretParams, policySigner, postPolicyGenerator, config.getGroupConfiguration(), externalGroupCredentialGenerator)); 107 | environment.jersey().register(new GroupsV1Controller(Clock.systemUTC(), groupsManager, serverSecretParams, policySigner, postPolicyGenerator, config.getGroupConfiguration(), externalGroupCredentialGenerator)); 108 | 109 | new MetricsHttpChannelListener().configure(environment); 110 | 111 | MetricsUtil.registerSystemResourceMetrics(environment); 112 | } 113 | 114 | public static void main(String[] argv) throws Exception { 115 | new StorageService().run(argv); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/main/proto/Groups.proto: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Signal Messenger, LLC 3 | * SPDX-License-Identifier: AGPL-3.0-only 4 | */ 5 | 6 | syntax = "proto3"; 7 | 8 | package signal; 9 | 10 | option java_package = "org.signal.storageservice.storage.protos.groups"; 11 | option java_outer_classname = "GroupProtos"; 12 | option java_multiple_files = true; 13 | 14 | message AvatarUploadAttributes { 15 | string key = 1; 16 | string credential = 2; 17 | string acl = 3; 18 | string algorithm = 4; 19 | string date = 5; 20 | string policy = 6; 21 | string signature = 7; 22 | } 23 | 24 | // Stored data 25 | 26 | message Member { 27 | enum Role { 28 | UNKNOWN = 0; 29 | DEFAULT = 1; 30 | ADMINISTRATOR = 2; 31 | } 32 | 33 | bytes userId = 1; 34 | Role role = 2; 35 | bytes profileKey = 3; 36 | bytes presentation = 4; 37 | uint32 joinedAtVersion = 5; 38 | } 39 | 40 | message MemberPendingProfileKey { 41 | Member member = 1; 42 | bytes addedByUserId = 2; 43 | uint64 timestamp = 3; // ms since epoch 44 | } 45 | 46 | message MemberPendingAdminApproval { 47 | bytes userId = 1; 48 | bytes profileKey = 2; 49 | bytes presentation = 3; 50 | uint64 timestamp = 4; // ms since epoch 51 | } 52 | 53 | message MemberBanned { 54 | bytes userId = 1; 55 | uint64 timestamp = 2; // ms since epoch 56 | } 57 | 58 | message AccessControl { 59 | enum AccessRequired { 60 | UNKNOWN = 0; 61 | ANY = 1; 62 | MEMBER = 2; 63 | ADMINISTRATOR = 3; 64 | UNSATISFIABLE = 4; 65 | } 66 | 67 | AccessRequired attributes = 1; 68 | AccessRequired members = 2; 69 | AccessRequired addFromInviteLink = 3; 70 | } 71 | 72 | message Group { 73 | bytes publicKey = 1; 74 | bytes title = 2; 75 | bytes description = 11; 76 | string avatar = 3; 77 | bytes disappearingMessagesTimer = 4; 78 | AccessControl accessControl = 5; 79 | uint32 version = 6; 80 | repeated Member members = 7; 81 | repeated MemberPendingProfileKey membersPendingProfileKey = 8; 82 | repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; 83 | bytes inviteLinkPassword = 10; 84 | bool announcements_only = 12; 85 | repeated MemberBanned members_banned = 13; 86 | // next: 14 87 | } 88 | 89 | message GroupJoinInfo { 90 | bytes publicKey = 1; 91 | bytes title = 2; 92 | bytes description = 8; 93 | string avatar = 3; 94 | uint32 memberCount = 4; 95 | AccessControl.AccessRequired addFromInviteLink = 5; 96 | uint32 version = 6; 97 | bool pendingAdminApproval = 7; 98 | bool pendingAdminApprovalFull = 9; 99 | // next: 10 100 | } 101 | 102 | // Deltas 103 | 104 | message GroupChange { 105 | 106 | message Actions { 107 | 108 | message AddMemberAction { 109 | Member added = 1; 110 | bool joinFromInviteLink = 2; 111 | } 112 | 113 | message DeleteMemberAction { 114 | bytes deletedUserId = 1; 115 | } 116 | 117 | message ModifyMemberRoleAction { 118 | bytes userId = 1; 119 | Member.Role role = 2; 120 | } 121 | 122 | message ModifyMemberProfileKeyAction { 123 | bytes presentation = 1; 124 | bytes user_id = 2; 125 | bytes profile_key = 3; 126 | } 127 | 128 | message AddMemberPendingProfileKeyAction { 129 | MemberPendingProfileKey added = 1; 130 | } 131 | 132 | message DeleteMemberPendingProfileKeyAction { 133 | bytes deletedUserId = 1; 134 | } 135 | 136 | message PromoteMemberPendingProfileKeyAction { 137 | bytes presentation = 1; 138 | bytes user_id = 2; 139 | bytes profile_key = 3; 140 | } 141 | 142 | message PromoteMemberPendingPniAciProfileKeyAction { 143 | bytes presentation = 1; 144 | bytes user_id = 2; 145 | bytes pni = 3; 146 | bytes profile_key = 4; 147 | } 148 | 149 | message AddMemberPendingAdminApprovalAction { 150 | MemberPendingAdminApproval added = 1; 151 | } 152 | 153 | message DeleteMemberPendingAdminApprovalAction { 154 | bytes deletedUserId = 1; 155 | } 156 | 157 | message PromoteMemberPendingAdminApprovalAction { 158 | bytes userId = 1; 159 | Member.Role role = 2; 160 | } 161 | 162 | message AddMemberBannedAction { 163 | MemberBanned added = 1; 164 | } 165 | 166 | message DeleteMemberBannedAction { 167 | bytes deletedUserId = 1; 168 | } 169 | 170 | message ModifyTitleAction { 171 | bytes title = 1; 172 | } 173 | 174 | message ModifyDescriptionAction { 175 | bytes description = 1; 176 | } 177 | 178 | message ModifyAvatarAction { 179 | string avatar = 1; 180 | } 181 | 182 | message ModifyDisappearingMessageTimerAction { 183 | bytes timer = 1; 184 | } 185 | 186 | message ModifyAttributesAccessControlAction { 187 | AccessControl.AccessRequired attributesAccess = 1; 188 | } 189 | 190 | message ModifyMembersAccessControlAction { 191 | AccessControl.AccessRequired membersAccess = 1; 192 | } 193 | 194 | message ModifyAddFromInviteLinkAccessControlAction { 195 | AccessControl.AccessRequired addFromInviteLinkAccess = 1; 196 | } 197 | 198 | message ModifyInviteLinkPasswordAction { 199 | bytes inviteLinkPassword = 1; 200 | } 201 | 202 | message ModifyAnnouncementsOnlyAction { 203 | bool announcements_only = 1; 204 | } 205 | 206 | bytes sourceUuid = 1; 207 | // clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group 208 | // if clients set it during a request the server will respond with 400. 209 | bytes group_id = 25; 210 | uint32 version = 2; 211 | 212 | repeated AddMemberAction addMembers = 3; 213 | repeated DeleteMemberAction deleteMembers = 4; 214 | repeated ModifyMemberRoleAction modifyMemberRoles = 5; 215 | repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; 216 | repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7; 217 | repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8; 218 | repeated PromoteMemberPendingProfileKeyAction promoteMembersPendingProfileKey = 9; 219 | ModifyTitleAction modifyTitle = 10; 220 | ModifyAvatarAction modifyAvatar = 11; 221 | ModifyDisappearingMessageTimerAction modifyDisappearingMessageTimer = 12; 222 | ModifyAttributesAccessControlAction modifyAttributesAccess = 13; 223 | ModifyMembersAccessControlAction modifyMemberAccess = 14; 224 | ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1 225 | repeated AddMemberPendingAdminApprovalAction addMembersPendingAdminApproval = 16; // change epoch = 1 226 | repeated DeleteMemberPendingAdminApprovalAction deleteMembersPendingAdminApproval = 17; // change epoch = 1 227 | repeated PromoteMemberPendingAdminApprovalAction promoteMembersPendingAdminApproval = 18; // change epoch = 1 228 | ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1 229 | ModifyDescriptionAction modifyDescription = 20; // change epoch = 2 230 | ModifyAnnouncementsOnlyAction modify_announcements_only = 21; // change epoch = 3 231 | repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4 232 | repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4 233 | repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5 234 | // next: 26 235 | } 236 | 237 | bytes actions = 1; 238 | bytes serverSignature = 2; 239 | uint32 changeEpoch = 3; 240 | } 241 | 242 | // External credentials 243 | 244 | message ExternalGroupCredential { 245 | string token = 1; 246 | } 247 | 248 | // API responses 249 | 250 | message GroupResponse { 251 | Group group = 1; 252 | bytes group_send_endorsements_response = 2; 253 | } 254 | 255 | message GroupChanges { 256 | message GroupChangeState { 257 | GroupChange groupChange = 1; 258 | Group groupState = 2; 259 | } 260 | 261 | repeated GroupChangeState groupChanges = 1; 262 | bytes group_send_endorsements_response = 2; 263 | } 264 | 265 | message GroupChangeResponse { 266 | GroupChange group_change = 1; 267 | bytes group_send_endorsements_response = 2; 268 | } 269 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | --------------------------------------------------------------------------------