diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 5530497590..57bb64e55a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt index 005602273f..b30aaa00ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberRepository.kt @@ -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 retryChangeLocalNumberNetworkOperation(operation: () -> NetworkResult): NetworkResult { diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/SealedSenderAccessUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/SealedSenderAccessUtil.java index 1af0990d2a..4edbe3addb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/SealedSenderAccessUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/SealedSenderAccessUtil.java @@ -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> getAccessMapFor(@NonNull List recipients, boolean isForStory) { - List> accessList = getAccessFor(recipients, isForStory, true); + List> accessList = getAccessFor(recipients, getSealedSenderCertificate(), isForStory, true); Iterator recipientIterator = recipients.iterator(); Iterator> accessIterator = accessList.iterator(); @@ -129,21 +138,16 @@ public class SealedSenderAccessUtil { } @WorkerThread - private static List> getAccessFor(@NonNull List recipients, boolean isForStory, boolean log) { - CertificateType certificateType = getUnidentifiedAccessCertificateType(); - byte[] ourUnidentifiedAccessCertificate = SignalStore.certificate().getUnidentifiedAccessCertificate(certificateType); - + private static List> getAccessFor(@NonNull List recipients, @Nullable SenderCertificate certificate, boolean isForStory, boolean log) { List> 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SealedSenderConstraint.kt b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SealedSenderConstraint.kt index 67a6860bc5..77a7f6f439 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SealedSenderConstraint.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SealedSenderConstraint.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt index 615010fc1b..644c8014ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt @@ -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) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java index 1825442ea5..a185d98d4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java index f64844f974..329285fd61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CertificateValues.java @@ -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. + *

+ * 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(); + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index 36b89fe0d7..5aeed7f532 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -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() } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt index 15d1e05593..8cb8234a12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index 21a4ee1d59..27f428cef2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -260,17 +260,19 @@ public final class GroupSendUtil { Set unregisteredTargets = allTargets.stream().filter(it -> it.isUnregistered() || it.isUnknown()).collect(Collectors.toSet()); List 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 = 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 results = sendOperation.sendLegacy(messageSender, legacyTargetAddresses, legacyTargets, SealedSenderAccess.forFanOutGroupSend(groupSendTokens, SealedSenderAccessUtil.getSealedSenderCertificate(), legacyTargetAccesses), recipientUpdate, result -> { + List results = sendOperation.sendLegacy(messageSender, legacyTargetAddresses, legacyTargets, SealedSenderAccess.forFanOutGroupSend(groupSendTokens, senderCertificate, legacyTargetAccesses), recipientUpdate, result -> { if (!includeInMessageLog) { return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java index 979be95ad5..a4d715bf7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListener.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index 217bfd45f5..739006c95b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -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 = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); return Single.fromCallable(() -> toSignalServiceAddress(context, recipient)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index e340767be0..180b917b5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -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); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListenerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListenerTest.kt new file mode 100644 index 0000000000..b15570dedb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/service/RotateSenderCertificateListenerTest.kt @@ -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) + } +}