Add key transparency backend support.

This commit is contained in:
Michelle Tang
2026-01-30 13:17:26 -05:00
committed by Greyson Parrelli
parent 26739491a5
commit 423b8c942c
18 changed files with 355 additions and 9 deletions

View File

@@ -199,6 +199,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val SORT_NAME = "sort_name"
const val IDENTITY_STATUS = "identity_status"
const val IDENTITY_KEY = "identity_key"
const val KEY_TRANSPARENCY_DATA = "key_transparency_data"
@JvmField
val CREATE_TABLE =
@@ -267,7 +268,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$NICKNAME_FAMILY_NAME TEXT DEFAULT NULL,
$NICKNAME_JOINED_NAME TEXT DEFAULT NULL,
$NOTE TEXT DEFAULT NULL,
$MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL
$MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL,
$KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL
)
"""
@@ -331,7 +333,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
PHONE_NUMBER_SHARING,
NICKNAME_GIVEN_NAME,
NICKNAME_FAMILY_NAME,
NOTE
NOTE,
KEY_TRANSPARENCY_DATA
)
private val ID_PROJECTION = arrayOf(ID)
@@ -4026,6 +4029,25 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
.readToSingleLong(0L)
}
fun getKeyTransparencyData(aci: ACI): ByteArray? {
return readableDatabase
.select(KEY_TRANSPARENCY_DATA)
.from(TABLE_NAME)
.where("$ACI_COLUMN = ?", aci.toString())
.run()
.readToSingleObject { cursor ->
cursor.requireBlob(KEY_TRANSPARENCY_DATA)
}
}
fun setKeyTransparencyData(aci: ACI, data: ByteArray?) {
writableDatabase
.update(TABLE_NAME)
.values(KEY_TRANSPARENCY_DATA to data)
.where("$ACI_COLUMN = ?", aci.toString())
.run()
}
/**
* Will update the database with the content values you specified. It will make an intelligent
* query such that this will only return true if a row was *actually* updated.

View File

@@ -165,7 +165,8 @@ object RecipientTableCursorUtil {
callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) },
phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) },
nickname = ProfileName.fromParts(cursor.requireString(RecipientTable.NICKNAME_GIVEN_NAME), cursor.requireString(RecipientTable.NICKNAME_FAMILY_NAME)),
note = cursor.requireString(RecipientTable.NOTE)
note = cursor.requireString(RecipientTable.NOTE),
keyTransparencyData = cursor.requireBlob(RecipientTable.KEY_TRANSPARENCY_DATA)
)
}

View File

@@ -153,6 +153,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V296_RemovePollVote
import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessageColumns
import org.thoughtcrime.securesms.database.helpers.migration.V298_DoNotBackupReleaseNotes
import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentMetadataTable
import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTransparencyColumn
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -312,10 +313,11 @@ object SignalDatabaseMigrations {
296 to V296_RemovePollVoteConstraint,
297 to V297_AddPinnedMessageColumns,
298 to V298_DoNotBackupReleaseNotes,
299 to V299_AddAttachmentMetadataTable
299 to V299_AddAttachmentMetadataTable,
300 to V300_AddKeyTransparencyColumn
)
const val DATABASE_VERSION = 299
const val DATABASE_VERSION = 300
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds column to recipient to track key transparency data
*/
@Suppress("ClassName")
object V300_AddKeyTransparencyColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE recipient ADD COLUMN key_transparency_data BLOB DEFAULT NULL")
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.database.model
import org.signal.core.util.logging.Log.tag
import org.signal.libsignal.keytrans.Store
import org.signal.libsignal.protocol.ServiceId
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.Optional
/**
* Store used by [org.signal.libsignal.net.KeyTransparencyClient] during key transparency
*/
data object KeyTransparencyStore : Store {
private val TAG: String = tag(KeyTransparencyStore::class.java)
override fun getLastDistinguishedTreeHead(): Optional<ByteArray> {
return Optional.ofNullable(SignalStore.account.distinguishedHead)
}
override fun setLastDistinguishedTreeHead(lastDistinguishedTreeHead: ByteArray) {
SignalStore.account.distinguishedHead = lastDistinguishedTreeHead
}
override fun getAccountData(libsignalAci: ServiceId.Aci): Optional<ByteArray> {
val aci = org.signal.core.models.ServiceId.ACI.fromLibSignal(libsignalAci)
return Optional.ofNullable(SignalDatabase.recipients.getKeyTransparencyData(aci))
}
override fun setAccountData(libsignalAci: ServiceId.Aci, data: ByteArray) {
val aci = org.signal.core.models.ServiceId.ACI.fromLibSignal(libsignalAci)
SignalDatabase.recipients.setKeyTransparencyData(aci, data)
}
}

View File

@@ -81,7 +81,8 @@ data class RecipientRecord(
val callLinkRoomId: CallLinkRoomId?,
val phoneNumberSharing: PhoneNumberSharingState,
val nickname: ProfileName,
val note: String?
val note: String?,
val keyTransparencyData: ByteArray? = null
) {
fun e164Only(): Boolean {

View File

@@ -369,6 +369,9 @@ object AppDependencies {
val donationsApi: DonationsApi
get() = networkModule.donationsApi
val keyTransparencyApi: KeyTransparencyApi
get() = networkModule.keyTransparencyApi
@JvmStatic
val okHttpClient: OkHttpClient
get() = networkModule.okHttpClient
@@ -463,5 +466,6 @@ object AppDependencies {
fun provideRemoteConfigApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): RemoteConfigApi
fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi
fun provideSvrBApi(libSignalNetwork: Network): SvrBApi
fun provideKeyTransparencyApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeyTransparencyApi
}
}

View File

@@ -579,6 +579,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new SvrBApi(libSignalNetwork);
}
@Override
public @NonNull KeyTransparencyApi provideKeyTransparencyApi(@NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket) {
return new KeyTransparencyApi(unauthWebSocket);
}
@VisibleForTesting
static class DynamicCredentialsProvider implements CredentialsProvider {

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.libsignal.internal.mapWithCancellation
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.keytrans.VerificationFailedException
import org.signal.libsignal.net.AppExpiredException
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.NetworkException
import org.signal.libsignal.net.NetworkProtocolException
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.TimeoutException
import org.signal.libsignal.net.getOrError
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.ServiceId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
/**
* Operations used when interacting with [org.signal.libsignal.net.KeyTransparencyClient]
*/
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
/**
* Uses KT to verify recipient. This is an unauthenticated and should only be called the first time KT is being requested for this recipient.
*/
suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String, unidentifiedAccessKey: ByteArray, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyError> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore)
.mapWithCancellation(
onSuccess = { RequestResult.Success(Unit) },
onError = { throwable ->
when (throwable) {
is TimeoutException,
is ServerSideErrorException,
is NetworkException,
is NetworkProtocolException -> {
RequestResult.RetryableNetworkError(throwable, null)
}
is RetryLaterException -> {
RequestResult.RetryableNetworkError(throwable, throwable.duration)
}
is VerificationFailedException,
is KeyTransparencyException,
is AppExpiredException,
is IllegalArgumentException -> {
RequestResult.NonSuccess(KeyTransparencyError(throwable))
}
else -> {
RequestResult.ApplicationError(throwable)
}
}
}
)
}.getOrError()
}
/**
* Monitors KT to verify recipient. This is an unauthenticated and should only be called following a successful [search].
*/
suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String, unidentifiedAccessKey: ByteArray, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyError> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore)
.mapWithCancellation(
onSuccess = { RequestResult.Success(Unit) },
onError = { throwable ->
when (throwable) {
is TimeoutException,
is ServerSideErrorException,
is NetworkException,
is NetworkProtocolException -> {
RequestResult.RetryableNetworkError(throwable, null)
}
is RetryLaterException -> {
RequestResult.RetryableNetworkError(throwable, throwable.duration)
}
is VerificationFailedException,
is KeyTransparencyException,
is AppExpiredException,
is IllegalArgumentException -> {
RequestResult.NonSuccess(KeyTransparencyError(throwable))
}
else -> {
RequestResult.ApplicationError(throwable)
}
}
}
)
}.getOrError()
}
}
data class KeyTransparencyError(val exception: Throwable) : BadRequestError

View File

@@ -225,6 +225,10 @@ class NetworkDependenciesModule(
provider.provideSvrBApi(libsignalNetwork)
}
val keyTransparencyApi: KeyTransparencyApi by lazy {
provider.provideKeyTransparencyApi(unauthWebSocket)
}
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor())

View File

@@ -88,6 +88,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY = "account.restore_account_entropy_pool_primary"
private const val KEY_KT_DISTINGUISHED_HEAD = "account.key_transparency_distinguished_head"
private val AEP_LOCK = ReentrantLock()
}
@@ -517,6 +518,17 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
.apply()
}
var distinguishedHead: ByteArray?
get() {
return getBlob(KEY_KT_DISTINGUISHED_HEAD, null)
}
set(value) {
store
.beginWrite()
.putBlob(KEY_KT_DISTINGUISHED_HEAD, value)
.apply()
}
/**
* There are some cases where our username may fall out of sync with the service. In particular, we may get a new value for our username from
* storage service but then find that it doesn't match what's on the service.

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.net
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
@@ -98,4 +99,7 @@ object SignalNetwork {
val svrB: SvrBApi
get() = AppDependencies.svrBApi
val keyTransparency: KeyTransparencyApi
get() = AppDependencies.keyTransparencyApi
}

View File

@@ -120,7 +120,8 @@ class Recipient(
private val groupRecord: Optional<GroupRecord> = Optional.empty(),
val phoneNumberSharing: PhoneNumberSharingState = PhoneNumberSharingState.UNKNOWN,
val nickname: ProfileName = ProfileName.EMPTY,
val note: String? = null
val note: String? = null,
val keyTransparencyData: ByteArray? = null
) {
/** The recipient's [ServiceId], which could be either an [ACI] or [PNI]. */
@@ -822,7 +823,8 @@ class Recipient(
callLinkRoomId == other.callLinkRoomId &&
phoneNumberSharing == other.phoneNumberSharing &&
nickname == other.nickname &&
note == other.note
note == other.note &&
keyTransparencyData.contentEquals(other.keyTransparencyData)
}
override fun equals(other: Any?): Boolean {

View File

@@ -202,7 +202,8 @@ object RecipientCreator {
groupRecord = groupRecord,
phoneNumberSharing = record.phoneNumberSharing,
nickname = record.nickname,
note = record.note
note = record.note,
keyTransparencyData = record.keyTransparencyData
)
}

View File

@@ -84,6 +84,13 @@ class VerifyDisplayFragment : Fragment() {
binding.caption.setLinkColor(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorPrimary))
viewModel.getAutomaticVerification().observe(viewLifecycleOwner) { status ->
if (status == AutomaticVerificationStatus.NONE) {
binding.autoVerifyContainer.setOnClickListener {
viewModel.verifyAutomatically()
}
} else {
binding.autoVerifyContainer.setOnClickListener(null)
}
updateStatus(status)
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.verify
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.RequestResult
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import java.time.Duration
/**
* Repository for safety numbers, namely to support key transparency / automatic verification
*/
object VerifySafetyNumberRepository {
private val TAG = Log.tag(VerifySafetyNumberRepository::class.java)
/**
* Given a recipient will try to verify via search (first time) or monitor (subsequent).
*/
suspend fun verifyAutomatically(recipient: Recipient): VerifyResult {
if (recipient.aci.isEmpty || recipient.e164.isEmpty) {
return VerifyResult.UnretryableFailure
}
val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipient.id)
val aciIdentityKey = identityRecord.get().identityKey
val aci = recipient.requireAci().libSignalAci
val e164 = recipient.requireE164()
val unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) }
val monitorMode = if (recipient.isSelf) KeyTransparency.MonitorMode.SELF else KeyTransparency.MonitorMode.OTHER
val firstSearch = recipient.keyTransparencyData == null
val result = if (firstSearch) {
Log.i(TAG, "First search in key transparency")
SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, KeyTransparencyStore)
} else {
Log.i(TAG, "Monitoring search in key transparency")
SignalNetwork.keyTransparency.monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, KeyTransparencyStore)
}
Log.i(TAG, "Key transparency complete, result: $result")
return when (result) {
is RequestResult.Success -> {
VerifyResult.Success
}
is RequestResult.NonSuccess -> {
if (result.error.exception is IllegalArgumentException) {
VerifyResult.CorruptedFailure
} else {
VerifyResult.UnretryableFailure
}
}
is RequestResult.RetryableNetworkError -> {
if (result.retryAfter != null) {
VerifyResult.RetryableFailure(result.retryAfter!!)
} else {
VerifyResult.UnretryableFailure
}
}
is RequestResult.ApplicationError -> VerifyResult.UnretryableFailure
}
}
sealed interface VerifyResult {
/** Successful verification */
data object Success : VerifyResult
/** Retryable failure **/
data class RetryableFailure(val duration: Duration) : VerifyResult
/** Failure when either the head or the data is corrupted. Retryable if both are reset. */
data object CorruptedFailure : VerifyResult
/** Failures that should not be retried. */
data object UnretryableFailure : VerifyResult
}
}

View File

@@ -10,6 +10,11 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKey
@@ -17,6 +22,7 @@ import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -43,6 +49,52 @@ class VerifySafetyNumberViewModel(
init {
initializeFingerprints()
checkAutomaticVerificationEligibility()
}
private fun checkAutomaticVerificationEligibility() {
if (recipient.get().e164.isEmpty || recipient.get().aci.isEmpty) {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_PERMANENT)
}
}
fun verifyAutomatically(canRetry: Boolean = true) {
viewModelScope.launch(SignalDispatchers.IO) {
if (automaticVerificationLiveData.value == AutomaticVerificationStatus.UNAVAILABLE_PERMANENT || !isActive) {
return@launch
}
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFYING)
when (val result = VerifySafetyNumberRepository.verifyAutomatically(recipient.get())) {
VerifySafetyNumberRepository.VerifyResult.Success -> {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFIED)
}
is VerifySafetyNumberRepository.VerifyResult.RetryableFailure -> {
if (canRetry) {
delay(result.duration.toMillis())
verifyAutomatically(canRetry = false)
} else {
Log.i(TAG, "Got a retryable exception, but we already retried once. Ignoring.")
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
}
}
VerifySafetyNumberRepository.VerifyResult.CorruptedFailure -> {
Log.w(TAG, "KT store was corrupted. Clearing everything and starting again.")
SignalStore.account.distinguishedHead = null
SignalDatabase.recipients.setKeyTransparencyData(recipient.get().requireAci(), null)
if (canRetry) {
verifyAutomatically(canRetry = false)
} else {
Log.i(TAG, "Store was corrupted and we can retry, but we already retried once. Ignoring.")
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
}
}
VerifySafetyNumberRepository.VerifyResult.UnretryableFailure -> {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
}
}
}
}
private fun initializeFingerprints() {