Improve protections around stale/invalid sender certificates.

This commit is contained in:
Cody Henthorne
2026-09-25 12:50:39 -04:00
committed by Michelle Tang
parent 0d8f7d7f99
commit 5be9220226
14 changed files with 256 additions and 95 deletions
@@ -226,7 +226,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addNonBlocking(this::initializeCircumvention)
.addNonBlocking(this::initializeCleanup)
.addNonBlocking(this::initializeGlideCodecs)
.addNonBlocking(SealedSenderConstraint::checkAndSetValidity)
.addNonBlocking(SealedSenderConstraint::refreshAndRotateIfNeeded)
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
.addNonBlocking(this::beginJobLoop)
.addNonBlocking(EmojiSource::refresh)
@@ -284,6 +284,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
startAnrDetector();
SignalExecutors.BOUNDED.execute(() -> {
SealedSenderConstraint.refreshAndRotateIfNeeded();
BackupRefreshJob.enqueueIfNecessary();
InAppPaymentAuthCheckJob.enqueueIfNeeded();
RemoteConfig.refreshIfNecessary();
@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNum
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
@@ -236,6 +237,8 @@ class ChangeNumberRepository(
SignalStore.certificate.setUnidentifiedAccessCertificate(certificateType, certificate)
}
RotateCertificateJob.markRotated()
}
private fun <T> retryChangeLocalNumberNetworkOperation(operation: () -> NetworkResult<T>): NetworkResult<T> {
@@ -17,6 +17,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode;
import org.thoughtcrime.securesms.database.model.RecipientRecord;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.keyvalue.CertificateType;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -51,11 +52,21 @@ public class SealedSenderAccessUtil {
@WorkerThread
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull Recipient recipient, boolean log) {
return SealedSenderAccess.forIndividual(getAccessFor(recipient, log));
return SealedSenderAccess.forIndividual(getAccessFor(recipient, getSealedSenderCertificate(), log));
}
/**
* Profile fetches only send the access key, so this tolerates an expired certificate and never triggers a rotation.
*/
@WorkerThread
public static @Nullable SealedSenderAccess getSealedSenderAccessForProfileFetch(@NonNull Recipient recipient, boolean log) {
return SealedSenderAccess.forIndividual(getAccessFor(recipient, getStoredSenderCertificate(), log));
}
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull Recipient recipient, @Nullable SealedSenderAccess.CreateGroupSendToken createGroupSendToken) {
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(recipient, true), getSealedSenderCertificate(), createGroupSendToken);
SenderCertificate certificate = getSealedSenderCertificate();
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(recipient, certificate, true), certificate, createGroupSendToken);
}
@WorkerThread
@@ -65,33 +76,31 @@ public class SealedSenderAccessUtil {
@WorkerThread
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull RecipientRecord record, boolean log) {
return SealedSenderAccess.forIndividual(getAccessFor(record, log));
return SealedSenderAccess.forIndividual(getAccessFor(record, getSealedSenderCertificate(), log));
}
public static @Nullable SealedSenderAccess getSealedSenderAccessFor(@NonNull RecipientRecord record, @Nullable SealedSenderAccess.CreateGroupSendToken createGroupSendToken) {
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(record, true), getSealedSenderCertificate(), createGroupSendToken);
SenderCertificate certificate = getSealedSenderCertificate();
return SealedSenderAccess.forIndividualWithGroupFallback(getAccessFor(record, certificate, true), certificate, createGroupSendToken);
}
@WorkerThread
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull Recipient recipient, boolean log) {
return getAccessFor(Collections.singletonList(recipient), false, log)
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull Recipient recipient, @Nullable SenderCertificate certificate, boolean log) {
return getAccessFor(Collections.singletonList(recipient), certificate, false, log)
.get(0)
.orElse(null);
}
@WorkerThread
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull RecipientRecord record, boolean log) {
byte[] ourUnidentifiedAccessCertificate = SignalStore.certificate().getUnidentifiedAccessCertificate(getUnidentifiedAccessCertificateType());
private static @Nullable UnidentifiedAccess getAccessFor(@NonNull RecipientRecord record, @Nullable SenderCertificate certificate, boolean log) {
UnidentifiedAccess unidentifiedAccess = null;
if (ourUnidentifiedAccessCertificate != null) {
if (certificate != null) {
try {
unidentifiedAccess = getTargetUnidentifiedAccess(record.getProfileKey(), getEffectiveSealedSenderAccessMode(record), ourUnidentifiedAccessCertificate, false);
unidentifiedAccess = getTargetUnidentifiedAccess(record.getProfileKey(), getEffectiveSealedSenderAccessMode(record), certificate.getSerialized(), false);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Invalid unidentified access certificate!", e);
}
} else {
Log.w(TAG, "Missing our unidentified access certificate!");
}
if (log) {
@@ -114,7 +123,7 @@ public class SealedSenderAccessUtil {
@WorkerThread
public static Map<RecipientId, Optional<UnidentifiedAccess>> getAccessMapFor(@NonNull List<Recipient> recipients, boolean isForStory) {
List<Optional<UnidentifiedAccess>> accessList = getAccessFor(recipients, isForStory, true);
List<Optional<UnidentifiedAccess>> accessList = getAccessFor(recipients, getSealedSenderCertificate(), isForStory, true);
Iterator<Recipient> recipientIterator = recipients.iterator();
Iterator<Optional<UnidentifiedAccess>> accessIterator = accessList.iterator();
@@ -129,21 +138,16 @@ public class SealedSenderAccessUtil {
}
@WorkerThread
private static List<Optional<UnidentifiedAccess>> getAccessFor(@NonNull List<Recipient> recipients, boolean isForStory, boolean log) {
CertificateType certificateType = getUnidentifiedAccessCertificateType();
byte[] ourUnidentifiedAccessCertificate = SignalStore.certificate().getUnidentifiedAccessCertificate(certificateType);
private static List<Optional<UnidentifiedAccess>> getAccessFor(@NonNull List<Recipient> recipients, @Nullable SenderCertificate certificate, boolean isForStory, boolean log) {
List<Optional<UnidentifiedAccess>> access = recipients.parallelStream().map(recipient -> {
UnidentifiedAccess unidentifiedAccess = null;
if (ourUnidentifiedAccessCertificate != null) {
if (certificate != null) {
try {
Recipient resolved = recipient.resolve();
unidentifiedAccess = getTargetUnidentifiedAccess(resolved.getProfileKey(), resolved.getSealedSenderAccessMode(), ourUnidentifiedAccessCertificate, isForStory);
unidentifiedAccess = getTargetUnidentifiedAccess(resolved.getProfileKey(), resolved.getSealedSenderAccessMode(), certificate.getSerialized(), isForStory);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Invalid unidentified access certificate!", e);
}
} else {
Log.w(TAG, "Missing our unidentified access certificate!");
}
return Optional.ofNullable(unidentifiedAccess);
}).collect(Collectors.toList());
@@ -158,20 +162,6 @@ public class SealedSenderAccessUtil {
return access;
}
public static @Nullable SenderCertificate getSealedSenderCertificate() {
byte[] unidentifiedAccessCertificate = getUnidentifiedAccessCertificate();
if (unidentifiedAccessCertificate == null) {
return null;
}
try {
return new SenderCertificate(unidentifiedAccessCertificate);
} catch (InvalidCertificateException e) {
Log.w(TAG, e);
return null;
}
}
private static @NonNull CertificateType getUnidentifiedAccessCertificateType() {
if (SignalStore.account().isPhoneNumberless()) {
return CertificateType.ACI_ONLY;
@@ -187,6 +177,54 @@ public class SealedSenderAccessUtil {
.getUnidentifiedAccessCertificate(getUnidentifiedAccessCertificateType());
}
private static @Nullable SenderCertificate getStoredSenderCertificate() {
byte[] certificateBytes = getUnidentifiedAccessCertificate();
if (certificateBytes == null) {
return null;
}
try {
return new SenderCertificate(certificateBytes);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Unable to parse our unidentified access certificate!", e);
return null;
}
}
/**
* Resolve once per access lookup: each null path re-runs {@link SealedSenderConstraint#refreshAndRotateIfNeeded()}, so calling this twice enqueues two
* rotations.
*/
public static @Nullable SenderCertificate getSealedSenderCertificate() {
byte[] certificateBytes = getUnidentifiedAccessCertificate();
if (certificateBytes == null) {
Log.w(TAG, "Missing our unidentified access certificate!");
SealedSenderConstraint.refreshAndRotateIfNeeded();
return null;
}
SenderCertificate certificate;
try {
certificate = new SenderCertificate(certificateBytes);
} catch (InvalidCertificateException e) {
Log.w(TAG, "Unable to parse our unidentified access certificate!", e);
SealedSenderConstraint.refreshAndRotateIfNeeded();
return null;
}
long now = SignalStore.misc().getEstimatedServerTime();
if (now >= certificate.getExpiration()) {
Log.w(TAG, "Our unidentified access certificate expired " + (now - certificate.getExpiration()) + " ms ago! Sending without sealed sender until it rotates.");
SealedSenderConstraint.refreshAndRotateIfNeeded();
return null;
}
return certificate;
}
private static @Nullable UnidentifiedAccess getTargetUnidentifiedAccess(@Nullable byte[] theirProfileKeyBytes, @NonNull SealedSenderAccessMode accessMode, @NonNull byte[] certificate, boolean isForStory) throws InvalidCertificateException {
ProfileKey theirProfileKey = ProfileKeyUtil.profileKeyOrNull(theirProfileKeyBytes);
@@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.jobmanager.ConstraintObserver
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Constraint that holds jobs until the sealed sender certificate is confirmed valid.
@@ -21,54 +20,56 @@ object SealedSenderConstraint : Constraint {
const val KEY = "SealedSenderConstraint"
private val TAG = Log.tag(SealedSenderConstraint::class.java)
private val CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1)
private val ROTATION_LEAD_TIME = TimeUnit.DAYS.toMillis(1)
private val valid = AtomicBoolean(false)
@Volatile
private var expiresAt: Long = 0
override fun isMet(): Boolean = valid.get()
override fun isMet(): Boolean = System.currentTimeMillis() < expiresAt
override fun getFactoryKey(): String = KEY
override fun applyToJobInfo(jobInfoBuilder: JobInfo.Builder) = Unit
@JvmStatic
fun markValid() {
valid.set(true)
Observer.onChange()
fun refresh() {
expiresAt = computeExpiresAt()
if (isMet()) {
Observer.onChange()
}
}
/**
* Checks all required certificate types. If all are present and not near expiry,
* marks the constraint as valid. Otherwise enqueues a [RotateCertificateJob] and
* leaves the constraint unmet until the rotation completes and calls [markValid].
*/
@JvmStatic
fun checkAndSetValidity() {
try {
val requiredTypes = SignalStore.phoneNumberPrivacy.getRequiredCertificateTypes()
fun refreshAndRotateIfNeeded() {
refresh()
if (System.currentTimeMillis() > expiresAt - ROTATION_LEAD_TIME) {
Log.w(TAG, "A sealed sender certificate is missing, expired, or nearly expired. Enqueuing rotation.")
AppDependencies.jobManager.add(RotateCertificateJob())
} else {
Log.i(TAG, "All sealed sender certificates are valid.")
}
}
private fun computeExpiresAt(): Long {
return try {
val requiredTypes = SignalStore.phoneNumberPrivacy.requiredCertificateTypes
var earliest = Long.MAX_VALUE
for (certificateType in requiredTypes) {
val certificateBytes = SignalStore.certificate.getUnidentifiedAccessCertificate(certificateType)
if (certificateBytes == null) {
Log.w(TAG, "Missing certificate $certificateType. Enqueuing rotation.")
AppDependencies.jobManager.add(RotateCertificateJob())
return
}
val certificate = SenderCertificate(certificateBytes)
if (System.currentTimeMillis() > certificate.expiration - CERTIFICATE_EXPIRATION_BUFFER) {
Log.w(TAG, "Certificate $certificateType is expired or near expiry. Enqueuing rotation.")
AppDependencies.jobManager.add(RotateCertificateJob())
return
}
val certificateBytes = SignalStore.certificate.getUnidentifiedAccessCertificate(certificateType) ?: return 0
earliest = minOf(earliest, SenderCertificate(certificateBytes).expiration)
}
Log.i(TAG, "All sealed sender certificates are valid.")
markValid()
if (requiredTypes.isEmpty()) {
Long.MAX_VALUE
} else {
earliest + SignalStore.misc.lastKnownServerTimeOffset
}
} catch (e: Exception) {
Log.w(TAG, "Error checking certificate validity. Enqueuing rotation.", e)
AppDependencies.jobManager.add(RotateCertificateJob())
Log.w(TAG, "Error reading certificate validity.", e)
0
}
}
@@ -123,7 +123,7 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
id = recipient.id,
serviceId = recipient.requireServiceId(),
profileKey = recipient.profileKey?.let { ProfileKey(it) },
sealedSenderAccess = SealedSenderAccessUtil.getSealedSenderAccessFor(recipient),
sealedSenderAccess = SealedSenderAccessUtil.getSealedSenderAccessForProfileFetch(recipient, true),
fetchExpiringCredential = !ExpiringProfileCredentialUtil.isValid(recipient.expiringProfileKeyCredential)
)
}
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
@@ -28,6 +29,7 @@ public final class RotateCertificateJob extends BaseJob {
public RotateCertificateJob() {
this(new Job.Parameters.Builder()
.setQueue("__ROTATE_SENDER_CERTIFICATE__")
.setMaxInstancesForFactory(1)
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
@@ -94,7 +96,13 @@ public final class RotateCertificateJob extends BaseJob {
}
}
SealedSenderConstraint.markValid();
markRotated();
}
@WorkerThread
public static void markRotated() {
SignalStore.certificate().setLastRotationTime(System.currentTimeMillis());
SealedSenderConstraint.refresh();
}
@Override
@@ -1,19 +1,36 @@
package org.thoughtcrime.securesms.keyvalue;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.preference.PreferenceManager;
import org.signal.core.util.logging.Log;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
public final class CertificateValues extends SignalStoreValues {
private static final String TAG = Log.tag(CertificateValues.class);
private static final String SEALED_SENDER_CERT_ACI_AND_E164 = "certificate.uuidAndE164";
private static final String SEALED_SENDER_CERT_ACI_ONLY = "certificate.uuidOnly";
private static final String LAST_ROTATION_TIME = "certificate.lastRotationTime";
CertificateValues(@NonNull KeyValueStore store) {
private static final long NEVER_ROTATED = -1;
private static final long LEGACY_ROTATION_INTERVAL = TimeUnit.DAYS.toMillis(1);
CertificateValues(@NonNull KeyValueStore store, @NonNull Context context) {
super(store);
if (!store.containsKey(LAST_ROTATION_TIME)) {
migrateFromSharedPrefsV1(context);
}
}
@Override
@@ -48,4 +65,30 @@ public final class CertificateValues extends SignalStoreValues {
}
}
public long getLastRotationTime() {
return getLong(LAST_ROTATION_TIME, NEVER_ROTATED);
}
public void setLastRotationTime(long lastRotationTime) {
putLong(LAST_ROTATION_TIME, lastRotationTime);
}
/**
* Do not alter. If you need to migrate more stuff, create a new method.
* <p>
* The legacy value held the next rotation time, always written as the clock at the last rotation plus a day.
*/
private void migrateFromSharedPrefsV1(@NonNull Context context) {
Log.i(TAG, "[V1] Migrating certificate values from shared prefs.");
SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
long legacyNextRotationTime = sharedPrefs.getLong("pref_unidentified_access_certificate_rotation_time", 0);
putLong(LAST_ROTATION_TIME, legacyNextRotationTime > 0 ? legacyNextRotationTime - LEGACY_ROTATION_INTERVAL : NEVER_ROTATED);
sharedPrefs.edit()
.remove("pref_unidentified_access_certificate_rotation_time")
.apply();
}
}
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint
import org.thoughtcrime.securesms.jobs.DeprecatedNotificationJob
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
@@ -261,6 +262,8 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
.putLong(SERVER_TIME_OFFSET, currentTime - serverTime)
.putLong(LAST_SERVER_TIME_OFFSET_UPDATE, System.currentTimeMillis())
.apply()
SealedSenderConstraint.refresh()
}
/**
@@ -22,7 +22,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
val internalValues = InternalValues(store)
val emojiValues = EmojiValues(store)
val settingsValues = SettingsValues(store, context)
val certificateValues = CertificateValues(store)
val certificateValues = CertificateValues(store, context)
val phoneNumberPrivacyValues = PhoneNumberPrivacyValues(store)
val onboardingValues = OnboardingValues(store)
val wallpaperValues = WallpaperValues(store)
@@ -260,17 +260,19 @@ public final class GroupSendUtil {
Set<Recipient> unregisteredTargets = allTargets.stream().filter(it -> it.isUnregistered() || it.isUnknown()).collect(Collectors.toSet());
List<Recipient> registeredTargets = allTargets.stream().filter(r -> !unregisteredTargets.contains(r)).collect(Collectors.toList());
SenderCertificate senderCertificate = SealedSenderAccessUtil.getSealedSenderCertificate();
if (senderCertificate == null) {
throw new IOException("No usable sealed sender certificate. Refusing to fall back to an unsealed send.");
}
RecipientData recipients = new RecipientData(context, registeredTargets, isStorySend);
Optional<GroupRecord> groupRecord = groupId != null ? SignalDatabase.groups().getGroup(groupId) : Optional.empty();
GroupSendEndorsementRecords groupSendEndorsementRecords = groupRecord.filter(GroupRecord::getHasV2GroupProperties).map(g -> SignalDatabase.groups().getGroupSendEndorsements(g.getId())).orElse(null);
long groupSendEndorsementExpiration = groupRecord.map(GroupRecord::getGroupSendEndorsementExpiration).orElse(0L);
SenderCertificate senderCertificate = SealedSenderAccessUtil.getSealedSenderCertificate();
boolean useGroupSendEndorsements = groupSendEndorsementRecords != null;
if (useGroupSendEndorsements && senderCertificate == null) {
Log.w(TAG, "Can't use group send endorsements without a sealed sender certificate, falling back to access key");
useGroupSendEndorsements = false;
} else if (useGroupSendEndorsements) {
if (useGroupSendEndorsements) {
boolean refreshGroupSendEndorsements = false;
if (groupSendEndorsementExpiration == 0) {
@@ -502,7 +504,7 @@ public final class GroupSendUtil {
final AtomicLong entryId = new AtomicLong(-1);
final boolean includeInMessageLog = sendOperation.shouldIncludeInMessageLog();
List<SendMessageResult> results = sendOperation.sendLegacy(messageSender, legacyTargetAddresses, legacyTargets, SealedSenderAccess.forFanOutGroupSend(groupSendTokens, SealedSenderAccessUtil.getSealedSenderCertificate(), legacyTargetAccesses), recipientUpdate, result -> {
List<SendMessageResult> results = sendOperation.sendLegacy(messageSender, legacyTargetAddresses, legacyTargets, SealedSenderAccess.forFanOutGroupSend(groupSendTokens, senderCertificate, legacyTargetAccesses), recipientUpdate, result -> {
if (!includeInMessageLog) {
return;
}
@@ -3,29 +3,41 @@ package org.thoughtcrime.securesms.service;
import android.content.Context;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.RotateCertificateJob;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import java.util.concurrent.TimeUnit;
public class RotateSenderCertificateListener extends PersistentAlarmManagerListener {
private static final String TAG = Log.tag(RotateSenderCertificateListener.class);
private static final long INTERVAL = TimeUnit.DAYS.toMillis(1);
@Override
protected long getNextScheduledExecutionTime(Context context) {
return TextSecurePreferences.getUnidentifiedAccessCertificateRotationTime(context);
return resolveNextExecutionTime(SignalStore.certificate().getLastRotationTime(), System.currentTimeMillis());
}
@Override
protected long onAlarm(Context context, long scheduledTime) {
AppDependencies.getJobManager().add(new RotateCertificateJob());
long nextTime = System.currentTimeMillis() + INTERVAL;
TextSecurePreferences.setUnidentifiedAccessCertificateRotationTime(context, nextTime);
return System.currentTimeMillis() + INTERVAL;
}
return nextTime;
@VisibleForTesting
static long resolveNextExecutionTime(long lastRotationTime, long now) {
if (lastRotationTime > now) {
Log.w(TAG, "Last rotation time is " + (lastRotationTime - now) + " ms in the future. Ignoring it and rotating now.");
return 0;
}
return lastRotationTime + INTERVAL;
}
public static void schedule(Context context) {
@@ -147,7 +147,7 @@ public final class ProfileUtil {
boolean allowUnidentifiedAccess)
{
ProfileService profileService = AppDependencies.getProfileService();
SealedSenderAccess sealedSenderAccess = allowUnidentifiedAccess ? SealedSenderAccessUtil.getSealedSenderAccessFor(recipient, false) : SealedSenderAccess.NONE;
SealedSenderAccess sealedSenderAccess = allowUnidentifiedAccess ? SealedSenderAccessUtil.getSealedSenderAccessForProfileFetch(recipient, false) : SealedSenderAccess.NONE;
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
return Single.fromCallable(() -> toSignalServiceAddress(context, recipient))
@@ -124,7 +124,6 @@ public class TextSecurePreferences {
private static final String NEEDS_MESSAGE_PULL = "pref_needs_message_pull";
private static final String UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF = "pref_unidentified_access_certificate_rotation_time";
public static final String UNIVERSAL_UNIDENTIFIED_ACCESS = "pref_universal_unidentified_access";
public static final String SHOW_UNIDENTIFIED_DELIVERY_INDICATORS = "pref_show_unidentifed_delivery_indicators";
private static final String UNIDENTIFIED_DELIVERY_ENABLED = "pref_unidentified_delivery_enabled";
@@ -439,14 +438,6 @@ public class TextSecurePreferences {
return getBooleanPreference(context, IN_THREAD_NOTIFICATION_PREF, true);
}
public static long getUnidentifiedAccessCertificateRotationTime(Context context) {
return getLongPreference(context, UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF, 0L);
}
public static void setUnidentifiedAccessCertificateRotationTime(Context context, long value) {
setLongPreference(context, UNIDENTIFIED_ACCESS_CERTIFICATE_ROTATION_TIME_PREF, value);
}
public static boolean isUniversalUnidentifiedAccess(Context context) {
return getBooleanPreference(context, UNIVERSAL_UNIDENTIFIED_ACCESS, false);
}
@@ -0,0 +1,59 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.BeforeClass
import org.junit.Test
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testutil.EmptyLogger
import java.util.concurrent.TimeUnit
class RotateSenderCertificateListenerTest {
companion object {
private val INTERVAL = TimeUnit.DAYS.toMillis(1)
private const val NOW = 1789734201707L
private const val NEVER_ROTATED = -1L
@JvmStatic
@BeforeClass
fun setUpClass() {
Log.initialize(EmptyLogger())
}
}
@Test
fun `given a rotation one interval ago, when resolving, then it is due now`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(NOW - INTERVAL, NOW)).isEqualTo(NOW)
}
@Test
fun `given a rotation just now, when resolving, then it is due one interval out`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(NOW, NOW)).isEqualTo(NOW + INTERVAL)
}
@Test
fun `given an overdue rotation, when resolving, then it is due in the past`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(NOW - (INTERVAL * 5), NOW)).isEqualTo(NOW - (INTERVAL * 4))
}
@Test
fun `given no recorded rotation, when resolving, then it is due in the past`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(NEVER_ROTATED, NOW)).isEqualTo(NEVER_ROTATED + INTERVAL)
}
@Test
fun `given a rotation barely in the future, when resolving, then it rotates now`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(NOW + 1, NOW)).isEqualTo(0)
}
@Test
fun `given a rotation written by a bad boot clock, when resolving, then it rotates now`() {
assertThat(RotateSenderCertificateListener.resolveNextExecutionTime(2279533251741L, NOW)).isEqualTo(0)
}
}