Centralize username logic in UsernameRepository.

This commit is contained in:
Greyson Parrelli 2023-11-08 14:56:33 -05:00 committed by Cody Henthorne
parent 0f4f87067e
commit e5ab5241d5
9 changed files with 105 additions and 158 deletions

View file

@ -50,6 +50,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.RxExtensions;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
@ -70,6 +71,8 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
@ -682,11 +685,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(username);
}, uuid -> {
try {
return RxExtensions.safeBlockingGet(UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username)));
} catch (InterruptedException e) {
Log.w(TAG, "Interrupted?", e);
return UsernameAciFetchResult.NetworkError.INSTANCE;
}
}, result -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
// TODO Could be more specific with errors
if (result instanceof UsernameAciFetchResult.Success success) {
Recipient recipient = Recipient.externalUsername(success.getAci(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {

View file

@ -15,9 +15,9 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeSt
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkQrColorPickerViewModel : ViewModel() {
@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
if (usernameLink != null) {
disposable += Single
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
.fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->

View file

@ -34,9 +34,9 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.Use
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
@ -48,7 +48,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
UsernameLinkSettingsState(
activeTab = ActiveTab.Code,
username = SignalStore.account().username!!,
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(it.toLink()) } ?: UsernameLinkState.NotSet,
qrCodeState = QrCodeState.Loading,
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
@ -61,7 +61,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
init {
disposable += usernameLink
.observeOn(Schedulers.io())
.map { link -> link.map { UsernameUtil.generateLink(it) } }
.map { link -> link.map { it.toLink() } }
.flatMapSingle { generateQrCodeData(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
@ -122,7 +122,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
_state.value = _state.value.copy(
usernameLinkState = if (components.isPresent) {
val link = UsernameUtil.generateLink(components.get())
val link = components.get().toLink()
UsernameLinkState.Present(link)
} else {
UsernameLinkState.NotSet
@ -152,7 +152,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
indeterminateProgress = true
)
disposable += UsernameRepository.convertLinkToUsernameAndAci(url)
disposable += UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))

View file

@ -23,10 +23,14 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotAssociatedWithAnAccountException
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.IOException
import java.util.UUID
/**
* Performs various actions around usernames and username links.
@ -77,6 +81,10 @@ import java.io.IOException
object UsernameRepository {
private val TAG = Log.tag(UsernameRepository::class.java)
private val URL_REGEX = """(https://)?signal.me/?#eu/([a-zA-Z0-9+\-_/]+)""".toRegex()
val BASE_URL = "https://signal.me/#eu/"
private val accountManager: SignalServiceAccountManager get() = ApplicationDependencies.getSignalServiceAccountManager()
/**
@ -163,8 +171,9 @@ object UsernameRepository {
/**
* Given a full username link, this will do the necessary parsing and network lookups to resolve it to a (username, ACI) pair.
*/
fun convertLinkToUsernameAndAci(url: String): Single<UsernameLinkConversionResult> {
val components: UsernameLinkComponents = UsernameUtil.parseLink(url) ?: return Single.just(UsernameLinkConversionResult.Invalid)
@JvmStatic
fun fetchUsernameAndAciFromLink(url: String): Single<UsernameLinkConversionResult> {
val components: UsernameLinkComponents = parseLink(url) ?: return Single.just(UsernameLinkConversionResult.Invalid)
return Single
.fromCallable {
@ -176,7 +185,7 @@ object UsernameRepository {
username = Username.fromLink(link)
val aci = accountManager.getAciByUsernameHash(UsernameUtil.hashUsernameToBase64(username.toString()))
val aci = accountManager.getAciByUsername(username)
UsernameLinkConversionResult.Success(username, aci)
} catch (e: IOException) {
@ -199,6 +208,49 @@ object UsernameRepository {
.subscribeOn(Schedulers.io())
}
@JvmStatic
fun fetchAciForUsername(username: String): Single<UsernameAciFetchResult> {
return Single.fromCallable {
try {
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsername(Username(username))
UsernameAciFetchResult.Success(aci)
} catch (e: UsernameIsNotAssociatedWithAnAccountException) {
Log.w(TAG, "[fetchAciFromUsername] Failed to get ACI for username hash", e)
UsernameAciFetchResult.NotFound
} catch (e: IOException) {
Log.w(TAG, "[fetchAciFromUsername] Hit network error while trying to resolve ACI from username", e)
UsernameAciFetchResult.NetworkError
}
}
}
/**
* 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.
*/
@JvmStatic
fun parseLink(url: String): UsernameLinkComponents? {
val match: MatchResult = URL_REGEX.find(url) ?: return null
val path: String = match.groups[2]?.value ?: return null
val allBytes: ByteArray = Base64.decode(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)
}
fun UsernameLinkComponents.toLink(): String {
val combined: ByteArray = this.entropy + this.serverId.toByteArray()
val base64 = Base64.encodeUrlSafeWithoutPadding(combined)
return BASE_URL + base64
}
@WorkerThread
private fun reserveUsernameInternal(nickname: String): Result<UsernameState.Reserved, UsernameSetResult> {
return try {
@ -320,4 +372,10 @@ object UsernameRepository {
/** No user exists for the given link. */
data class NotFound(val username: Username?) : UsernameLinkConversionResult()
}
sealed class UsernameAciFetchResult {
class Success(val aci: ACI) : UsernameAciFetchResult()
object NotFound : UsernameAciFetchResult()
object NetworkError : UsernameAciFetchResult()
}
}

View file

@ -22,6 +22,7 @@ import androidx.fragment.app.FragmentActivity;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.RxExtensions;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
@ -42,6 +43,9 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoin
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinUpdateRequiredBottomSheetDialogFragment;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameLinkConversionResult;
import org.thoughtcrime.securesms.proxy.ProxyBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId;
@ -296,15 +300,12 @@ public class CommunicationActions {
*/
public static void handlePotentialSignalMeUrl(@NonNull FragmentActivity activity, @NonNull String potentialUrl) {
String e164 = SignalMeUtil.parseE164FromLink(activity, potentialUrl);
UsernameLinkComponents username = SignalMeUtil.parseUsernameComponentsFromLink(potentialUrl);
UsernameLinkComponents username = UsernameRepository.parseLink(potentialUrl);
if (e164 != null) {
handleE164Link(activity, e164);
} else if (username != null) {
handleUsernameLink(activity, username);
}
if (e164 != null || username != null) {
handleUsernameLink(activity, potentialUrl);
}
}
@ -460,25 +461,21 @@ public class CommunicationActions {
});
}
private static void handleUsernameLink(Activity activity, UsernameLinkComponents link) {
private static void handleUsernameLink(Activity activity, String link) {
SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(activity, 500, 500);
SimpleTask.run(() -> {
try {
byte[] encryptedUsername = ApplicationDependencies.getSignalServiceAccountManager().getEncryptedUsernameFromLinkServerId(link.getServerId());
Username username = Username.fromLink(new Username.UsernameLink(link.getEntropy(), encryptedUsername));
Optional<ServiceId> serviceId = UsernameUtil.fetchAciForUsername(username.getUsername());
UsernameLinkConversionResult result = RxExtensions.safeBlockingGet(UsernameRepository.fetchUsernameAndAciFromLink(link));
if (serviceId.isPresent()) {
return Recipient.externalUsername(serviceId.get(), username.getUsername());
// TODO we could be better here and report different types of errors to the UI
if (result instanceof UsernameLinkConversionResult.Success success) {
return Recipient.externalUsername(success.getAci(), success.getUsername().getUsername());
} else {
return null;
}
} catch (IOException e) {
Log.w(TAG, "Failed to fetch encrypted username", e);
return null;
} catch (BaseUsernameException e) {
Log.w(TAG, "Invalid username", e);
} catch (InterruptedException e) {
Log.w(TAG, "Interrupted?", e);
return null;
}
}, recipient -> {

View file

@ -2,17 +2,11 @@ package org.thoughtcrime.securesms.util
import android.content.Context
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.util.UuidUtil
import java.io.IOException
import java.io.UnsupportedEncodingException
import java.util.Locale
internal object SignalMeUtil {
private val E164_REGEX = """^(https|sgnl)://signal\.me/#p/(\+[0-9]+)$""".toRegex()
private val USERNAME_REGEX = """^(https|sgnl)://signal\.me/#eu/(.+)$""".toRegex()
/**
* If this is a valid signal.me link and has a valid e164, it will return the e164. Otherwise, it will return null.
@ -33,31 +27,4 @@ internal object SignalMeUtil {
}
}
}
/**
* If this is a valid signal.me link and has valid username link components, it will return those components. Otherwise, it will return null.
*/
@JvmStatic
fun parseUsernameComponentsFromLink(link: String?): UsernameLinkComponents? {
if (link.isNullOrBlank()) {
return null
}
return USERNAME_REGEX.find(link)?.let { match ->
val usernameLinkBase64: String = match.groups[2]?.value ?: return@let null
try {
val usernameLinkData: ByteArray = Base64.decode(usernameLinkBase64).takeIf { it.size == 48 } ?: return@let null
val entropy: ByteArray = usernameLinkData.sliceArray(0 until 32)
val uuidBytes: ByteArray = usernameLinkData.sliceArray(32 until usernameLinkData.size)
val uuid = UuidUtil.parseOrNull(uuidBytes)
UsernameLinkComponents(entropy, uuid)
} catch (e: UnsupportedEncodingException) {
null
} catch (e: IOException) {
null
}
}
}
}

View file

@ -1,21 +1,7 @@
package org.thoughtcrime.securesms.util
import androidx.annotation.WorkerThread
import org.signal.core.util.Base64
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 java.io.IOException
import java.util.Locale
import java.util.Optional
import java.util.UUID
import java.util.regex.Pattern
object UsernameUtil {
@ -24,24 +10,29 @@ object UsernameUtil {
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"
private val SEARCH_PATTERN = Pattern.compile(
String.format(
Locale.US,
"^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}(.[0-9]+)?$",
"^@?[a-zA-Z_][a-zA-Z0-9_]{%d,%d}(.[0-9]+)?$",
MIN_LENGTH - 1,
MAX_LENGTH - 1,
Pattern.CASE_INSENSITIVE
)
)
@JvmStatic
fun isValidUsernameForSearch(value: String): Boolean {
return value.isNotEmpty() && SEARCH_PATTERN.matcher(value).matches()
}
@JvmStatic
fun sanitizeUsernameFromSearch(value: String): String {
return value.replace("[^a-zA-Z0-9_.]".toRegex(), "")
}
@JvmStatic
fun checkUsername(value: String?): InvalidReason? {
return when {
@ -66,81 +57,6 @@ object UsernameUtil {
}
}
@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(Base64.encodeUrlSafeWithoutPadding(Username(username).hash))
} 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 Base64.encodeUrlSafeWithoutPadding(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 = Base64.encodeUrlSafeWithoutPadding(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 = Base64.decode(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,

View file

@ -84,7 +84,6 @@ import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper
import org.signal.core.util.Base64;
import java.io.IOException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
@ -761,8 +760,8 @@ public class SignalServiceAccountManager {
}
}
public ACI getAciByUsernameHash(String usernameHash) throws IOException {
return this.pushServiceSocket.getAciByUsernameHash(usernameHash);
public ACI getAciByUsername(Username username) throws IOException {
return this.pushServiceSocket.getAciByUsernameHash(Base64.encodeUrlSafeWithoutPadding(username.getHash()));
}
public ReserveUsernameResponse reserveUsername(List<String> usernameHashes) throws IOException {

View file

@ -1068,7 +1068,7 @@ public class PushServiceSocket {
byte[] randomness = new byte[32];
random.nextBytes(randomness);
byte[] proof = Username.generateProof(username, randomness);
byte[] proof = new Username(username).generateProofWithRandomness(randomness);
ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsernameHash(),
Base64.encodeUrlSafeWithoutPadding(proof));