Add capability for KT username syncs.

This commit is contained in:
Michelle Tang 2026-06-03 10:03:01 -04:00 committed by Alex Hart
parent 1f0c24a5d5
commit 1371663163
24 changed files with 187 additions and 33 deletions

View File

@ -139,7 +139,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()

View File

@ -133,7 +133,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@ -157,7 +157,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)

View File

@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
)
}
}

View File

@ -7,7 +7,12 @@ package org.thoughtcrime.securesms.components.settings.conversation
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
@ -98,18 +103,18 @@ data class InternalConversationSettingsState(
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id)
if (capabilities != null) {
AnnotatedString("No capabilities right now.")
// Left as an example in case we add one in the future
// val style: SpanStyle = when (capabilities.storageServiceEncryptionV2) {
// Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
// Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
// Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
// }
//
// buildAnnotatedString {
// withStyle(style = style) {
// append("SSREv2")
// }
// }
// Always leave one as an example in case we add one in the future
val style: SpanStyle = when (capabilities.usernameSyncMessages) {
Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
}
buildAnnotatedString {
withStyle(style = style) {
append("usernameSyncMessages")
}
}
} else {
AnnotatedString("Recipient not found!")
}

View File

@ -424,6 +424,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
fun maskCapabilitiesToLong(capabilities: SignalServiceProfile.Capabilities): Long {
var value: Long = 0
value = Bitmask.update(value, Capabilities.STORAGE_SERVICE_ENCRYPTION_V2, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStorageServiceEncryptionV2).serialize().toLong())
value = Bitmask.update(value, Capabilities.USERNAME_SYNC_MESSAGES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isUsernameSyncMessages).serialize().toLong())
return value
}
}
@ -4953,8 +4954,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
// const val DELETE_SYNC = 9
// const val VERSIONED_EXPIRATION_TIMER = 10
const val STORAGE_SERVICE_ENCRYPTION_V2 = 11
const val USERNAME_SYNC_MESSAGES = 12
// IMPORTANT: We cannot sore more than 32 capabilities in the bitmask.
// IMPORTANT: We cannot store more than 32 capabilities in the bitmask.
}
enum class VibrateState(val id: Int) {

View File

@ -10,6 +10,7 @@ import android.database.Cursor
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.Bitmask
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.optionalBlob
@ -174,7 +175,8 @@ object RecipientTableCursorUtil {
fun readCapabilities(cursor: Cursor): RecipientRecord.Capabilities {
val capabilities = cursor.requireLong(RecipientTable.CAPABILITIES)
return RecipientRecord.Capabilities(
rawBits = capabilities
rawBits = capabilities,
usernameSyncMessages = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.USERNAME_SYNC_MESSAGES, RecipientTable.Capabilities.BIT_LENGTH).toInt())
)
}

View File

@ -121,12 +121,14 @@ data class RecipientRecord(
)
data class Capabilities(
val rawBits: Long
val rawBits: Long,
val usernameSyncMessages: Recipient.Capability
) {
companion object {
@JvmField
val UNKNOWN = Capabilities(
rawBits = 0
rawBits = 0,
usernameSyncMessages = Recipient.Capability.UNKNOWN
)
}
}

View File

@ -117,11 +117,11 @@ class CheckKeyTransparencyJob private constructor(
aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey,
e164 = recipient.e164!!,
unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) },
usernameHash = SignalStore.account.username?.let { Username(it).hash },
usernameHash = SignalStore.account.username?.let { Username(it).hash }.takeIf { Recipient.self().usernameSyncMessagesCapability.isSupported },
keyTransparencyStore = KeyTransparencyStore
)
Log.i(TAG, "Key transparency complete, result: $result")
Log.i(TAG, "Key transparency complete, result: $result. Included username in check: ${Recipient.self().usernameSyncMessagesCapability.isSupported}")
return when (result) {
is RequestResult.Success -> {
SignalStore.misc.hasKeyTransparencyFailure = false

View File

@ -224,6 +224,7 @@ public final class JobManagerFactories {
put(MultiDeviceStickerPackSyncJob.KEY, new MultiDeviceStickerPackSyncJob.Factory());
put(MultiDeviceStorageSyncRequestJob.KEY, new MultiDeviceStorageSyncRequestJob.Factory());
put(MultiDeviceSubscriptionSyncRequestJob.KEY, new MultiDeviceSubscriptionSyncRequestJob.Factory());
put(MultiDeviceUsernameChangeSyncJob.KEY, new MultiDeviceUsernameChangeSyncJob.Factory());
put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory());
put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory());
put(MultiDeviceViewedUpdateJob.KEY, new MultiDeviceViewedUpdateJob.Factory());

View File

@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.jobs
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.pad
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.IOException
import java.util.Optional
import kotlin.time.Duration.Companion.days
/**
* Sends a sync message to alert linked devices of a username change so they can reset KT.
*/
class MultiDeviceUsernameChangeSyncJob private constructor(
parameters: Parameters
) : Job(parameters) {
companion object {
const val KEY = "MultiDeviceUsernameChangeSyncJob"
private val TAG = Log.tag(MultiDeviceUsernameChangeSyncJob::class.java)
@WorkerThread
@JvmStatic
fun enqueueUsernameChangeSync() {
if (!SignalStore.account.isMultiDevice) {
return
}
AppDependencies.jobManager.add(
MultiDeviceUsernameChangeSyncJob(
parameters = Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(1.days.inWholeMilliseconds)
.build()
)
)
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (!Recipient.self().isRegistered) {
Log.w(TAG, "Not registered")
return Result.failure()
}
if (!SignalStore.account.isMultiDevice) {
Log.w(TAG, "Not multi-device")
return Result.failure()
}
val syncMessageContent = Content(
syncMessage = SyncMessage.Builder()
.pad()
.usernameChange(SyncMessage.UsernameChange())
.build()
)
return try {
Log.d(TAG, "Sending username change sync")
val success = AppDependencies.signalServiceMessageSender.sendSyncMessage(syncMessageContent, true, Optional.empty()).isSuccess
if (success) {
Result.success()
} else {
Log.w(TAG, "Unsuccessful username change send. Retrying.")
Result.retry(defaultBackoff())
}
} catch (e: IOException) {
Log.w(TAG, "Unable to send username change sync due to io exception", e)
Result.retry(defaultBackoff())
} catch (e: UntrustedIdentityException) {
Log.w(TAG, "Unable to send username change sync due to untrusted exception", e)
Result.failure()
}
}
override fun onFailure() = Unit
class Factory : Job.Factory<MultiDeviceUsernameChangeSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceUsernameChangeSyncJob {
return MultiDeviceUsernameChangeSyncJob(parameters)
}
}
}

View File

@ -14,6 +14,7 @@ import org.signal.core.util.Util
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotEmpty
import org.signal.core.util.orNull
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.InvalidKeyException
@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.PaymentMetaDataUtil
import org.thoughtcrime.securesms.database.SentStorySyncManifest
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -58,6 +60,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.database.withAttachments
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupId
@ -188,6 +191,7 @@ object SyncMessageProcessor {
syncMessage.deleteForMe != null -> handleSynchronizeDeleteForMe(context, syncMessage.deleteForMe!!, envelope.clientTimestamp!!, earlyMessageCacheEntry)
syncMessage.attachmentBackfillRequest != null -> handleSynchronizeAttachmentBackfillRequest(syncMessage.attachmentBackfillRequest!!, envelope.clientTimestamp!!)
syncMessage.attachmentBackfillResponse != null -> warn(envelope.clientTimestamp!!, "Contains a backfill response, but we don't handle these!")
syncMessage.usernameChange != null -> handleSynchronizeUsernameChange(envelope.clientTimestamp!!)
else -> warn(envelope.clientTimestamp!!, "Contains no known sync types...")
}
}
@ -2026,6 +2030,12 @@ object SyncMessageProcessor {
return threadId
}
private fun handleSynchronizeUsernameChange(timestamp: Long) {
log(timestamp, "[handleSynchronizeUsernameChange] Synchronize username change. Resetting KT.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.USERNAME_HASH, keyTransparencyStore = KeyTransparencyStore)
}
private fun ConversationIdentifier.toRecipientId(): RecipientId? {
val threadServiceId = ServiceId.parseOrNull(this.threadServiceId, this.threadServiceIdBinary)
return when {

View File

@ -20,9 +20,13 @@ import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkResetResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceUsernameChangeSyncJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.confirmUsernameAndCreateNewLink
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.reserveUsername
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.updateUsernameDisplayForCurrentLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.NetworkUtil
@ -444,6 +448,9 @@ object UsernameRepository {
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[updateUsernameDisplayForCurrentLink] Successfully updated username.")
@ -477,6 +484,9 @@ object UsernameRepository {
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[confirmUsernameAndCreateNewLink] Successfully confirmed username.")
@ -534,6 +544,10 @@ object UsernameRepository {
SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[deleteUsername] Successfully deleted the username.")

View File

@ -352,6 +352,9 @@ class Recipient(
sealedSenderAccessModeValue
}
/** The user's capability to receive username sync messages */
val usernameSyncMessagesCapability: Capability = capabilities.usernameSyncMessages
/** The wallpaper to render as the chat background, if present. */
val wallpaper: ChatWallpaper?
get() {

View File

@ -731,7 +731,8 @@ class AppRegistrationNetworkController(
storage,
versionedExpirationTimer,
attachmentBackfill,
spqr
spqr,
usernameChangeSyncMessage
)
}

View File

@ -368,7 +368,7 @@ class ApiPlugin : Plugin {
}
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, profileSharing)
if (registered) {

View File

@ -122,7 +122,8 @@ object RecipientDatabaseTestUtils {
notificationChannel = notificationChannel,
sealedSenderAccessMode = sealedSenderAccessMode,
capabilities = RecipientRecord.Capabilities(
rawBits = capabilities
rawBits = capabilities,
usernameSyncMessages = Recipient.Capability.SUPPORTED
),
storageId = storageId,
mentionSetting = mentionSetting,

View File

@ -184,7 +184,7 @@ class RecipientTestRule : TestRule {
val id = SignalDatabase.recipients.getOrInsertFromServiceId(aci)
SignalDatabase.recipients.setProfileName(id, profileName)
SignalDatabase.recipients.setProfileKeyIfAbsent(id, ProfileKey(Random.nextBytes(32)))
SignalDatabase.recipients.setCapabilities(id, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(id, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(id, profileSharing)
SignalDatabase.recipients.markRegistered(id, aci)
return id

View File

@ -885,7 +885,8 @@ class DemoNetworkController(
storage = !RegistrationPreferences.pinsOptedOut,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
@ -1127,7 +1128,8 @@ class DemoNetworkController(
storage,
versionedExpirationTimer,
attachmentBackfill,
spqr
spqr,
usernameChangeSyncMessage
)
}

View File

@ -182,7 +182,8 @@ class PinSettingsViewModel(
storage = !newOptedOut,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,

View File

@ -369,7 +369,8 @@ interface NetworkController {
val storage: Boolean,
val versionedExpirationTimer: Boolean,
val attachmentBackfill: Boolean,
val spqr: Boolean
val spqr: Boolean,
val usernameChangeSyncMessage: Boolean
)
}

View File

@ -369,7 +369,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ
storage = true, // True initially -- can turn off later if users opt-out
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it
),
name = null,
pniRegistrationId = keyMaterial.pniRegistrationId,

View File

@ -57,6 +57,7 @@ class AccountAttributes @JsonCreator constructor(
@JsonProperty val storage: Boolean,
@JsonProperty val versionedExpirationTimer: Boolean,
@JsonProperty val attachmentBackfill: Boolean,
@JsonProperty val spqr: Boolean
@JsonProperty val spqr: Boolean,
@JsonProperty val usernameChangeSyncMessage: Boolean
)
}

View File

@ -195,12 +195,16 @@ public class SignalServiceProfile {
@JsonProperty("ssre2")
private boolean storageServiceEncryptionV2;
@JsonProperty("usernameChangeSyncMessage")
private boolean usernameSyncMessages;
@JsonCreator
public Capabilities() {}
public Capabilities(boolean storage, boolean storageServiceEncryptionV2) {
public Capabilities(boolean storage, boolean storageServiceEncryptionV2, boolean usernameSyncMessages) {
this.storage = storage;
this.storageServiceEncryptionV2 = storageServiceEncryptionV2;
this.usernameSyncMessages = usernameSyncMessages;
}
public boolean isStorage() {
@ -210,6 +214,10 @@ public class SignalServiceProfile {
public boolean isStorageServiceEncryptionV2() {
return storageServiceEncryptionV2;
}
public boolean isUsernameSyncMessages() {
return usernameSyncMessages;
}
}
public ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse() {

View File

@ -850,6 +850,8 @@ message SyncMessage {
}
}
message UsernameChange {}
oneof content {
Sent sent = 1;
Contacts contacts = 2;
@ -870,6 +872,7 @@ message SyncMessage {
DeviceNameChange deviceNameChange = 23;
AttachmentBackfillRequest attachmentBackfillRequest = 24;
AttachmentBackfillResponse attachmentBackfillResponse = 25;
UsernameChange usernameChange = 26;
}
reserved /*groups*/ 3;