Skip to content

Commit

Permalink
Generic credential auth endpoint for call links
Browse files Browse the repository at this point in the history
  • Loading branch information
katherine-signal committed Apr 4, 2023
1 parent 48ebafa commit e4da59c
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 51 deletions.
3 changes: 3 additions & 0 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,6 @@ registrationService:
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
AAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
callLink:
userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with calling frontend to generate auth tokens for Signal users
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
Expand Down Expand Up @@ -219,6 +220,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private ArtServiceConfiguration artService;

@Valid
@NotNull
@JsonProperty
private CallLinkConfiguration callLink;

@Valid
@NotNull
@JsonProperty
Expand Down Expand Up @@ -371,6 +377,10 @@ public DatadogConfiguration getDatadogConfiguration() {
return datadog;
}

public CallLinkConfiguration getCallLinkConfiguration() {
return callLink;
}

public UnidentifiedDeliveryConfiguration getDeliveryCertificate() {
return unidentifiedDelivery;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
import org.whispersystems.textsecuregcm.controllers.ArtController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.controllers.ChallengeController;
import org.whispersystems.textsecuregcm.controllers.DeviceController;
Expand Down Expand Up @@ -490,6 +491,9 @@ public DistributionStatisticConfig configure(final Id id, final DistributionStat
config.getArtServiceConfiguration());
ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(
config.getSvr2Configuration());
ExternalServiceCredentialsGenerator callLinkCredentialsGenerator = CallLinkController.credentialsGenerator(
config.getCallLinkConfiguration()
);

dynamicConfigurationManager.start();

Expand Down Expand Up @@ -774,6 +778,7 @@ public DistributionStatisticConfig configure(final Id id, final DistributionStat
new ArtController(rateLimiters, artCredentialsGenerator),
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()),
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()),
new CallLinkController(callLinkCredentialsGenerator),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock),
new ChallengeController(rateLimitChallengeManager),
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmacHexStringsEqual;

import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;

Expand All @@ -28,6 +30,10 @@ public class ExternalServiceCredentialsGenerator {

private final boolean truncateSignature;

private final String usernameTimestampPrefix;

private final Function<Instant, Instant> usernameTimestampTruncator;

private final Clock clock;

private final int truncateLength;
Expand All @@ -41,14 +47,22 @@ private ExternalServiceCredentialsGenerator(
final byte[] userDerivationKey,
final boolean prependUsername,
final boolean truncateSignature,
final Clock clock,
final int truncateLength) {
final int truncateLength,
final String usernameTimestampPrefix,
final Function<Instant, Instant> usernameTimestampTruncator,
final Clock clock) {
this.key = requireNonNull(key);
this.userDerivationKey = requireNonNull(userDerivationKey);
this.prependUsername = prependUsername;
this.truncateSignature = truncateSignature;
this.usernameTimestampPrefix = usernameTimestampPrefix;
this.usernameTimestampTruncator = usernameTimestampTruncator;
this.clock = requireNonNull(clock);
this.truncateLength = truncateLength;

if (hasUsernameTimestampPrefix() ^ hasUsernameTimestampTruncator()) {
throw new RuntimeException("Configured to have only one of (usernameTimestampPrefix, usernameTimestampTruncator)");
}
}

/**
Expand All @@ -66,13 +80,34 @@ public ExternalServiceCredentials generateForUuid(final UUID uuid) {
* @return an instance of {@link ExternalServiceCredentials}
*/
public ExternalServiceCredentials generateFor(final String identity) {
if (usernameIsTimestamp()) {
throw new RuntimeException("Configured to use timestamp as username");
}

return generate(identity);
}

/**
* Generates `ExternalServiceCredentials` using a prefix concatenated with a truncated timestamp as the username, following this generator's configuration.
* @return an instance of {@link ExternalServiceCredentials}
*/
public ExternalServiceCredentials generateWithTimestampAsUsername() {
if (!usernameIsTimestamp()) {
throw new RuntimeException("Not configured to use timestamp as username");
}

final String truncatedTimestampSeconds = String.valueOf(usernameTimestampTruncator.apply(clock.instant()).getEpochSecond());
return generate(usernameTimestampPrefix + DELIMITER + truncatedTimestampSeconds);
}

private ExternalServiceCredentials generate(final String identity) {
final String username = shouldDeriveUsername()
? hmac256TruncatedToHexString(userDerivationKey, identity, truncateLength)
: identity;

final long currentTimeSeconds = currentTimeSeconds();

final String dataToSign = username + DELIMITER + currentTimeSeconds;
final String dataToSign = usernameIsTimestamp() ? username : username + DELIMITER + currentTimeSeconds;

final String signature = truncateSignature
? hmac256TruncatedToHexString(key, dataToSign, truncateLength)
Expand All @@ -84,7 +119,7 @@ public ExternalServiceCredentials generateFor(final String identity) {
}

/**
* In certain cases, identity (as it was passed to `generateFor` method)
* In certain cases, identity (as it was passed to `generate` method)
* is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself.
* For such cases, this method returns the value of the identity string.
* @param password `password` part of `ExternalServiceCredentials`
Expand All @@ -96,9 +131,15 @@ public Optional<String> identityFromSignature(final String password) {
return Optional.empty();
}
// checking for the case of unexpected format
return StringUtils.countMatches(password, DELIMITER) == 2
? Optional.of(password.substring(0, password.indexOf(DELIMITER)))
: Optional.empty();
if (StringUtils.countMatches(password, DELIMITER) == 2) {
if (usernameIsTimestamp()) {
final int indexOfSecondDelimiter = password.indexOf(DELIMITER, password.indexOf(DELIMITER) + 1);
return Optional.of(password.substring(0, indexOfSecondDelimiter));
} else {
return Optional.of(password.substring(0, password.indexOf(DELIMITER)));
}
}
return Optional.empty();
}

/**
Expand All @@ -115,7 +156,7 @@ public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials c

// making sure password format matches our expectations based on the generator configuration
if (parts.length == 3 && prependUsername) {
final String username = parts[0];
final String username = usernameIsTimestamp() ? parts[0] + DELIMITER + parts[1] : parts[0];
// username has to match the one from `credentials`
if (!credentials.username().equals(username)) {
return Optional.empty();
Expand All @@ -130,7 +171,7 @@ public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials c
return Optional.empty();
}

final String signedData = credentials.username() + DELIMITER + timestampSeconds;
final String signedData = usernameIsTimestamp() ? credentials.username() : credentials.username() + DELIMITER + timestampSeconds;
final String expectedSignature = truncateSignature
? hmac256TruncatedToHexString(key, signedData, truncateLength)
: hmac256ToHexString(key, signedData);
Expand Down Expand Up @@ -158,6 +199,18 @@ private boolean shouldDeriveUsername() {
return userDerivationKey.length > 0;
}

private boolean hasUsernameTimestampPrefix() {
return usernameTimestampPrefix != null;
}

private boolean hasUsernameTimestampTruncator() {
return usernameTimestampTruncator != null;
}

private boolean usernameIsTimestamp() {
return hasUsernameTimestampPrefix() && hasUsernameTimestampTruncator();
}

private long currentTimeSeconds() {
return clock.instant().getEpochSecond();
}
Expand All @@ -174,6 +227,10 @@ public static class Builder {

private int truncateLength = 10;

private String usernameTimestampPrefix = null;

private Function<Instant, Instant> usernameTimestampTruncator = null;

private Clock clock = Clock.systemUTC();


Expand Down Expand Up @@ -208,9 +265,15 @@ public Builder truncateSignature(final boolean truncateSignature) {
return this;
}

public Builder withUsernameTimestampTruncatorAndPrefix(final Function<Instant, Instant> truncator, final String prefix) {
this.usernameTimestampTruncator = truncator;
this.usernameTimestampPrefix = prefix;
return this;
}

public ExternalServiceCredentialsGenerator build() {
return new ExternalServiceCredentialsGenerator(
key, userDerivationKey, prependUsername, truncateSignature, clock, truncateLength);
key, userDerivationKey, prependUsername, truncateSignature, truncateLength, usernameTimestampPrefix, usernameTimestampTruncator, clock);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.whispersystems.textsecuregcm.configuration;

import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import javax.validation.constraints.NotEmpty;
import java.util.HexFormat;

public record CallLinkConfiguration (@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.whispersystems.textsecuregcm.controllers;

import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.time.Clock;
import java.time.temporal.ChronoUnit;
import java.util.Optional;

@Path("/v1/call-link")
@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink")
public class CallLinkController {
@VisibleForTesting
public static final String ANONYMOUS_CREDENTIAL_PREFIX = "anon";

private final ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator;

public CallLinkController(
ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator
) {
this.callingFrontendServiceCredentialGenerator = callingFrontendServiceCredentialGenerator;
}

public static ExternalServiceCredentialsGenerator credentialsGenerator(final CallLinkConfiguration cfg) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), ANONYMOUS_CREDENTIAL_PREFIX)
.build();
}
@Timed
@GET
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Generate credentials for calling frontend",
description = """
These credentials enable clients to prove to calling frontend that they were a Signal user within the last day.
For client privacy, timestamps are truncated to 1 day granularity and the token does not include or derive from an ACI.
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
return callingFrontendServiceCredentialGenerator.generateWithTimestampAsUsername();
}
}

0 comments on commit e4da59c

Please sign in to comment.