Update to the new username link spec.

This commit is contained in:
Greyson Parrelli
2023-08-25 09:33:57 -04:00
parent a6dd4345ab
commit 8a93814bac
47 changed files with 1283 additions and 463 deletions

View File

@@ -1,135 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UsernameUtil {
private static final String TAG = Log.tag(UsernameUtil.class);
public static final int MIN_LENGTH = 3;
public static final int MAX_LENGTH = 32;
private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE);
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
private static final Pattern URL_PATTERN = Pattern.compile("(https://)?signal.me/#u/([a-zA-Z0-9+/]*={0,2})");
private static final String BASE_URL_SCHEMELESS = "signal.me/#u/";
private static final String BASE_URL = "https://" + BASE_URL_SCHEMELESS;
public static boolean isValidUsernameForSearch(@Nullable String value) {
return !TextUtils.isEmpty(value) && !DIGIT_START_PATTERN.matcher(value).matches();
}
public static Optional<InvalidReason> checkUsername(@Nullable String value) {
if (value == null) {
return Optional.of(InvalidReason.TOO_SHORT);
} else if (value.length() < MIN_LENGTH) {
return Optional.of(InvalidReason.TOO_SHORT);
} else if (value.length() > MAX_LENGTH) {
return Optional.of(InvalidReason.TOO_LONG);
} else if (DIGIT_START_PATTERN.matcher(value).matches()) {
return Optional.of(InvalidReason.STARTS_WITH_NUMBER);
} else if (!FULL_PATTERN.matcher(value).matches()) {
return Optional.of(InvalidReason.INVALID_CHARACTERS);
} else {
return Optional.empty();
}
}
@WorkerThread
public static @NonNull Optional<ServiceId> fetchAciForUsername(@NonNull String username) {
Optional<RecipientId> localId = SignalDatabase.recipients().getByUsername(username);
if (localId.isPresent()) {
Recipient recipient = Recipient.resolved(localId.get());
if (recipient.getServiceId().isPresent()) {
Log.i(TAG, "Found username locally -- using associated UUID.");
return recipient.getServiceId();
} else {
Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it.");
SignalDatabase.recipients().clearUsernameIfExists(username);
}
}
Log.d(TAG, "No local user with this username. Searching remotely.");
try {
return fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
} catch (BaseUsernameException e) {
return Optional.empty();
}
}
/**
* Hashes a username to a url-safe base64 string.
* @throws BaseUsernameException If the username is invalid and un-hashable.
*/
public static String hashUsernameToBase64(String username) throws BaseUsernameException {
return Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username));
}
@WorkerThread
public static @NonNull Optional<ServiceId> fetchAciForUsernameHash(@NonNull String base64UrlSafeEncodedUsernameHash) {
try {
ACI aci = ApplicationDependencies.getSignalServiceAccountManager()
.getAciByUsernameHash(base64UrlSafeEncodedUsernameHash);
return Optional.ofNullable(aci);
} catch (IOException e) {
return Optional.empty();
}
}
public static String generateLink(String username) {
String base64 = Base64UrlSafe.encodeBytesWithoutPadding(username.getBytes(StandardCharsets.UTF_8));
return BASE_URL + base64;
}
/**
* Parses the username from a link if possible, otherwise null.
*/
public static @Nullable String parseLink(String url) {
Matcher matcher = URL_PATTERN.matcher(url);
if (!matcher.matches()) {
return null;
}
String base64 = matcher.group(2);
if (base64 == null) {
return null;
}
try {
return new String(Base64.decodeWithoutPadding(base64));
} catch (IOException e) {
return null;
}
}
public enum InvalidReason {
TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER
}
}

View File

@@ -0,0 +1,133 @@
package org.thoughtcrime.securesms.util
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.signal.libsignal.usernames.BaseUsernameException
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import org.whispersystems.util.Base64UrlSafe
import java.io.IOException
import java.util.Locale
import java.util.Optional
import java.util.UUID
import java.util.regex.Pattern
object UsernameUtil {
private val TAG = Log.tag(UsernameUtil::class.java)
const val MIN_LENGTH = 3
const val MAX_LENGTH = 32
private val FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE)
private val DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$")
private val URL_PATTERN = """(https://)?signal.me/?#eu/([a-zA-Z0-9+\-_/]+)""".toRegex()
private const val BASE_URL_SCHEMELESS = "signal.me/#eu/"
private const val BASE_URL = "https://$BASE_URL_SCHEMELESS"
fun isValidUsernameForSearch(value: String): Boolean {
return value.isNotEmpty() && !DIGIT_START_PATTERN.matcher(value).matches()
}
@JvmStatic
fun checkUsername(value: String?): Optional<InvalidReason> {
return if (value == null) {
Optional.of(InvalidReason.TOO_SHORT)
} else if (value.length < MIN_LENGTH) {
Optional.of(InvalidReason.TOO_SHORT)
} else if (value.length > MAX_LENGTH) {
Optional.of(InvalidReason.TOO_LONG)
} else if (DIGIT_START_PATTERN.matcher(value).matches()) {
Optional.of(InvalidReason.STARTS_WITH_NUMBER)
} else if (!FULL_PATTERN.matcher(value).matches()) {
Optional.of(InvalidReason.INVALID_CHARACTERS)
} else {
Optional.empty()
}
}
@JvmStatic
@WorkerThread
fun fetchAciForUsername(username: String): Optional<ServiceId> {
val localId = recipients.getByUsername(username)
if (localId.isPresent) {
val recipient = Recipient.resolved(localId.get())
if (recipient.serviceId.isPresent) {
Log.i(TAG, "Found username locally -- using associated UUID.")
return recipient.serviceId
} else {
Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it.")
recipients.clearUsernameIfExists(username)
}
}
Log.d(TAG, "No local user with this username. Searching remotely.")
return try {
fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)))
} catch (e: BaseUsernameException) {
Optional.empty()
}
}
/**
* Hashes a username to a url-safe base64 string.
* @throws BaseUsernameException If the username is invalid and un-hashable.
*/
@Throws(BaseUsernameException::class)
fun hashUsernameToBase64(username: String?): String {
return Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
}
@JvmStatic
@WorkerThread
fun fetchAciForUsernameHash(base64UrlSafeEncodedUsernameHash: String): Optional<ServiceId> {
return try {
val aci = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(base64UrlSafeEncodedUsernameHash)
Optional.ofNullable(aci)
} catch (e: IOException) {
Log.w(TAG, "Failed to get ACI for username hash", e)
Optional.empty()
}
}
/**
* Generates a username link from the provided [UsernameLinkComponents].
*/
fun generateLink(components: UsernameLinkComponents): String {
val combined: ByteArray = components.entropy + components.serverId.toByteArray()
val base64 = Base64UrlSafe.encodeBytesWithoutPadding(combined)
return BASE_URL + base64
}
/**
* Parses out the [UsernameLinkComponents] from a link if possible, otherwise null.
* You need to make a separate network request to convert these components into a username.
*/
fun parseLink(url: String): UsernameLinkComponents? {
val match: MatchResult = URL_PATTERN.find(url) ?: return null
val path: String = match.groups[2]?.value ?: return null
val allBytes: ByteArray = Base64UrlSafe.decodePaddingAgnostic(path)
if (allBytes.size != 48) {
return null
}
val entropy: ByteArray = allBytes.slice(0 until 32).toByteArray()
val serverId: ByteArray = allBytes.slice(32 until allBytes.size).toByteArray()
val serverIdUuid: UUID = UuidUtil.parseOrNull(serverId) ?: return null
return UsernameLinkComponents(entropy = entropy, serverId = serverIdUuid)
}
enum class InvalidReason {
TOO_SHORT,
TOO_LONG,
INVALID_CHARACTERS,
STARTS_WITH_NUMBER
}
}