Skip to content

Commit

Permalink
Add v4 attachment controller
Browse files Browse the repository at this point in the history
Add AttachmentControllerV4 which can be configured to generate upload
forms for a TUS based CDN
  • Loading branch information
ravi-signal committed Jul 21, 2023
1 parent 9df923d commit 705fb93
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 33 deletions.
2 changes: 2 additions & 0 deletions service/config/sample-secrets-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789
svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users
svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users

tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=

awsAttachments.accessKey: test
awsAttachments.accessSecret: test

Expand Down
4 changes: 4 additions & 0 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ gcpAttachments: # GCP Storage configuration
pathPrefix:
rsaSigningKey: secret://gcpAttachments.rsaSigningKey

tus:
uploadUri: https://example.org/upload
userAuthenticationTokenSharedSecret: secret://tus.userAuthenticationTokenSharedSecret

accountDatabaseCrawler:
chunkSize: 10 # accounts per run

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.Set;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
Expand Down Expand Up @@ -270,6 +271,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private TurnSecretConfiguration turn;

@Valid
@NotNull
@JsonProperty
private TusConfiguration tus;

@Valid
@NotNull
@JsonProperty
Expand Down Expand Up @@ -454,6 +460,10 @@ public TurnSecretConfiguration getTurnSecretConfiguration() {
return turn;
}

public TusConfiguration getTus() {
return tus;
}

public int getGrpcPort() {
return grpcPort;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator;
Expand All @@ -89,6 +91,7 @@
import org.whispersystems.textsecuregcm.controllers.ArtController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.controllers.ChallengeController;
Expand Down Expand Up @@ -596,6 +599,14 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
.credentialsProvider(cdnCredentialsProvider)
.region(Region.of(config.getCdnConfiguration().region()))
.build();

final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(
config.getGcpAttachmentsConfiguration().domain(),
config.getGcpAttachmentsConfiguration().email(),
config.getGcpAttachmentsConfiguration().maxSizeInBytes(),
config.getGcpAttachmentsConfiguration().pathPrefix(),
config.getGcpAttachmentsConfiguration().rsaSigningKey().value());

PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket(), config.getCdnConfiguration().accessKey().value());
PolicySigner profileCdnPolicySigner = new PolicySigner(config.getCdnConfiguration().accessSecret().value(),
Expand Down Expand Up @@ -718,7 +729,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
registrationLockVerificationManager, rateLimiters),
new ArtController(rateLimiters, artCredentialsGenerator),
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().accessKey().value(), config.getAwsAttachmentsConfiguration().accessSecret().value(), config.getAwsAttachmentsConfiguration().region(), config.getAwsAttachmentsConfiguration().bucket()),
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().domain(), config.getGcpAttachmentsConfiguration().email(), config.getGcpAttachmentsConfiguration().maxSizeInBytes(), config.getGcpAttachmentsConfiguration().pathPrefix(), config.getGcpAttachmentsConfiguration().rsaSigningKey().value()),
new AttachmentControllerV3(rateLimiters, gcsAttachmentGenerator),
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, new TusAttachmentGenerator(config.getTus()), experimentEnrollmentManager),
new CallLinkController(rateLimiters, genericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), zkAuthOperations, genericZkSecretParams, clock),
new ChallengeController(rateLimitChallengeManager),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.attachments;
import java.util.Map;

public interface AttachmentGenerator {

record Descriptor(Map<String, String> headers, String signedUploadLocation) {}

Descriptor generateAttachment(final String key);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.attachments;

import org.whispersystems.textsecuregcm.gcp.CanonicalRequest;
import org.whispersystems.textsecuregcm.gcp.CanonicalRequestGenerator;
import org.whispersystems.textsecuregcm.gcp.CanonicalRequestSigner;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.spec.InvalidKeySpecException;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Map;

public class GcsAttachmentGenerator implements AttachmentGenerator {
@Nonnull
private final CanonicalRequestGenerator canonicalRequestGenerator;

@Nonnull
private final CanonicalRequestSigner canonicalRequestSigner;

public GcsAttachmentGenerator(@Nonnull String domain, @Nonnull String email,
int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey)
throws IOException, InvalidKeyException, InvalidKeySpecException {
this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix);
this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey);
}

@Override
public Descriptor generateAttachment(final String key) {
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now);
return new Descriptor(getHeaderMap(canonicalRequest), getSignedUploadLocation(canonicalRequest));
}

private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) {
return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath()
+ '?' + canonicalRequest.getCanonicalQuery()
+ "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest);
}

private static Map<String, String> getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) {
return Map.of(
"host", canonicalRequest.getDomain(),
"x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes(),
"x-goog-resumable", "start");
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.attachments;

import org.apache.http.HttpHeaders;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.Base64;
import java.util.Map;

public class TusAttachmentGenerator implements AttachmentGenerator {

private static final String ATTACHMENTS = "attachments";

final ExternalServiceCredentialsGenerator credentialsGenerator;
final String tusUri;

public TusAttachmentGenerator(final TusConfiguration cfg) {
this.tusUri = cfg.uploadUri();
this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);
}

private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock, final TusConfiguration cfg) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.prependUsername(false)
.withClock(clock)
.build();
}

@Override
public Descriptor generateAttachment(final String key) {
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(ATTACHMENTS + "/" + key);
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
final Map<String, String> headers = Map.of(
HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),
"Upload-Metadata", String.format("filename %s", b64Key)
);
return new Descriptor(headers, tusUri + "/" + ATTACHMENTS);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.attachments;

import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import javax.validation.constraints.NotEmpty;

public record TusConfiguration(
@ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,
@NotEmpty String uploadUri
){}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.gcp.CanonicalRequest;
Expand All @@ -37,20 +39,15 @@ public class AttachmentControllerV3 {
private final RateLimiter rateLimiter;

@Nonnull
private final CanonicalRequestGenerator canonicalRequestGenerator;

@Nonnull
private final CanonicalRequestSigner canonicalRequestSigner;
private final GcsAttachmentGenerator gcsAttachmentGenerator;

@Nonnull
private final SecureRandom secureRandom;

public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull String domain, @Nonnull String email,
int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey)
public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull GcsAttachmentGenerator gcsAttachmentGenerator)
throws IOException, InvalidKeyException, InvalidKeySpecException {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix);
this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey);
this.gcsAttachmentGenerator = gcsAttachmentGenerator;
this.secureRandom = new SecureRandom();
}

Expand All @@ -61,26 +58,9 @@ public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull Strin
public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedAccount auth)
throws RateLimitExceededException {
rateLimiter.validate(auth.getAccount().getUuid());

final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
final String key = generateAttachmentKey();
final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now);

return new AttachmentDescriptorV3(2, key, getHeaderMap(canonicalRequest),
getSignedUploadLocation(canonicalRequest));
}

private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) {
return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath()
+ '?' + canonicalRequest.getCanonicalQuery()
+ "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest);
}

private static Map<String, String> getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) {
return Map.of(
"host", canonicalRequest.getDomain(),
"x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes(),
"x-goog-resumable", "start");
final AttachmentGenerator.Descriptor descriptor = this.gcsAttachmentGenerator.generateAttachment(key);
return new AttachmentDescriptorV3(2, key, descriptor.headers(), descriptor.signedUploadLocation());
}

private String generateAttachmentKey() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.controllers;

import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;


/**
* The V4 API is identical to the {@link AttachmentControllerV3} API, but supports an additional TUS based cdn type (cdn3)
*/
@Path("/v4/attachments")
@Tag(name = "Attachments")
public class AttachmentControllerV4 {

public static final String CDN3_EXPERIMENT_NAME = "cdn3";

private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final RateLimiter rateLimiter;

private final Map<Integer, AttachmentGenerator> attachmentGenerators;

@Nonnull
private final SecureRandom secureRandom;

public AttachmentControllerV4(
final RateLimiters rateLimiters,
final GcsAttachmentGenerator gcsAttachmentGenerator,
final TusAttachmentGenerator tusAttachmentGenerator,
final ExperimentEnrollmentManager experimentEnrollmentManager) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.secureRandom = new SecureRandom();
this.attachmentGenerators = Map.of(
2, gcsAttachmentGenerator,
3, tusAttachmentGenerator
);
}

@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/form/upload")
@Operation(
summary = "Get an upload form",
description = """
Retrieve an upload form that can be used to perform a resumable upload. The response will include a cdn number
indicating what protocol should be used to perform the upload.
"""
)
@ApiResponse(responseCode = "200", description = "Success, response body includes upload form", useReturnTypeSchema = true)
@ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedAccount auth)
throws RateLimitExceededException {
rateLimiter.validate(auth.getAccount().getUuid());
final String key = generateAttachmentKey();
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccount().getUuid(), CDN3_EXPERIMENT_NAME);
int cdn = useCdn3 ? 3 : 2;
final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);
return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation());
}

private String generateAttachmentKey() {
final byte[] bytes = new byte[15];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().encodeToString(bytes);
}
}

Loading

0 comments on commit 705fb93

Please sign in to comment.