diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 7f03743aa8..ff9a15f1a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.FontDownloaderJob; import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob; import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; +import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob; import org.thoughtcrime.securesms.jobs.PreKeysSyncJob; @@ -215,6 +216,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp()) .addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary) .addPostRender(GroupRingCleanupJob::enqueue) + .addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary) .execute(); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java index e11a0ecf82..4333ca36c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -29,6 +29,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.qr.kitkat.ScanListener; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; import org.signal.core.util.Base64; @@ -231,6 +232,8 @@ public class DeviceActivity extends PassphraseRequiredActivity protected void onPostExecute(Integer result) { super.onPostExecute(result); + LinkedDeviceInactiveCheckJob.enqueue(); + Context context = DeviceActivity.this; switch (result) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java index 88957b70dd..87e9e40faf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceListFragment.java @@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.loaders.DeviceListLoader; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.devicelist.Device; +import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; @@ -166,6 +167,7 @@ public class DeviceListFragment extends ListFragment super.onPostExecute(result); if (result) { getLoaderManager().restartLoader(0, null, DeviceListFragment.this); + LinkedDeviceInactiveCheckJob.enqueue(); } else { Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java index 9d3da9efb4..c843433097 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java @@ -17,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.util.ByteUtil; import org.thoughtcrime.securesms.devicelist.Device; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher; import org.thoughtcrime.securesms.util.AsyncLoader; import org.signal.core.util.Base64; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -78,50 +79,19 @@ public class DeviceListLoader extends AsyncLoader> { throw new IOException("Got a DeviceName that wasn't properly populated."); } - return new Device(deviceInfo.getId(), new String(decryptName(deviceName, SignalStore.account().getAciIdentityKey())), deviceInfo.getCreated(), deviceInfo.getLastSeen()); + byte[] plaintext = DeviceNameCipher.decryptDeviceName(deviceName, SignalStore.account().getAciIdentityKey()); + if (plaintext == null) { + throw new IOException("Failed to decrypt device name."); + } + return new Device(deviceInfo.getId(), new String(plaintext), deviceInfo.getCreated(), deviceInfo.getLastSeen()); } catch (IOException e) { Log.w(TAG, "Failed while reading the protobuf.", e); - } catch (GeneralSecurityException | InvalidKeyException e) { - Log.w(TAG, "Failed during decryption.", e); } return new Device(deviceInfo.getId(), deviceInfo.getName(), deviceInfo.getCreated(), deviceInfo.getLastSeen()); } - @VisibleForTesting - public static byte[] decryptName(DeviceName deviceName, IdentityKeyPair identityKeyPair) throws InvalidKeyException, GeneralSecurityException { - byte[] syntheticIv = Objects.requireNonNull(deviceName.syntheticIv).toByteArray(); - byte[] cipherText = Objects.requireNonNull(deviceName.ciphertext).toByteArray(); - ECPrivateKey identityKey = identityKeyPair.getPrivateKey(); - ECPublicKey ephemeralPublic = Curve.decodePoint(Objects.requireNonNull(deviceName.ephemeralPublic).toByteArray(), 0); - byte[] masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey); - - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(masterSecret, "HmacSHA256")); - byte[] cipherKeyPart1 = mac.doFinal("cipher".getBytes()); - - mac.init(new SecretKeySpec(cipherKeyPart1, "HmacSHA256")); - byte[] cipherKey = mac.doFinal(syntheticIv); - - Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(new byte[16])); - final byte[] plaintext = cipher.doFinal(cipherText); - - mac.init(new SecretKeySpec(masterSecret, "HmacSHA256")); - byte[] verificationPart1 = mac.doFinal("auth".getBytes()); - - mac.init(new SecretKeySpec(verificationPart1, "HmacSHA256")); - byte[] verificationPart2 = mac.doFinal(plaintext); - byte[] ourSyntheticIv = ByteUtil.trim(verificationPart2, 16); - - if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { - throw new GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv."); - } - - return plaintext; - } - private static class DeviceComparator implements Comparator { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 6acc40a3a5..4bd168873a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -138,6 +138,7 @@ public final class JobManagerFactories { put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory()); put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory()); put(LegacyAttachmentUploadJob.KEY, new LegacyAttachmentUploadJob.Factory()); + put(LinkedDeviceInactiveCheckJob.KEY, new LinkedDeviceInactiveCheckJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory()); put(MarkerJob.KEY, new MarkerJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt new file mode 100644 index 0000000000..78d573535d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log +import org.signal.core.util.roundedString +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.devicelist.protos.DeviceName +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice +import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import java.io.IOException +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit + +/** + * Designed as a routine check to keep an eye on how active our linked devices are. + */ +class LinkedDeviceInactiveCheckJob private constructor( + parameters: Parameters = Parameters.Builder() + .setQueue("LinkedDeviceInactiveCheckJob") + .setMaxInstancesForFactory(2) + .setLifespan(30.days.inWholeMilliseconds) + .setMaxAttempts(Parameters.UNLIMITED) + .addConstraint(NetworkConstraint.KEY) + .build() +) : Job(parameters) { + + companion object { + private val TAG = Log.tag(LinkedDeviceInactiveCheckJob::class.java) + const val KEY = "LinkedDeviceInactiveCheckJob" + + @JvmStatic + fun enqueue() { + ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob()) + } + + @JvmStatic + fun enqueueIfNecessary() { + val timeSinceLastCheck = System.currentTimeMillis() - SignalStore.misc().linkedDeviceLastActiveCheckTime + if (timeSinceLastCheck > 1.days.inWholeMilliseconds || timeSinceLastCheck < 0) { + ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob()) + } + } + } + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + val devices = try { + ApplicationDependencies.getSignalServiceAccountManager().devices + } catch (e: IOException) { + return Result.retry(defaultBackoff()) + } + + if (devices.isEmpty()) { + Log.i(TAG, "No linked devices found.") + + SignalStore.misc().hasLinkedDevices = false + SignalStore.misc().leastActiveLinkedDevice = null + SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis() + + return Result.success() + } + + val leastActiveDevice: LeastActiveLinkedDevice? = devices + .filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID } + .filter { it.name != null } + .minBy { it.lastSeen } + .let { + val nameProto = DeviceName.ADAPTER.decode(Base64.decode(it.getName())) + val decryptedBytes = DeviceNameCipher.decryptDeviceName(nameProto, ApplicationDependencies.getProtocolStore().aci().identityKeyPair) ?: return@let null + val name = String(decryptedBytes) + + LeastActiveLinkedDevice( + name = name, + lastActiveTimestamp = it.lastSeen + ) + } + + if (leastActiveDevice == null) { + Log.w(TAG, "Failed to decrypt linked device name.") + SignalStore.misc().hasLinkedDevices = true + SignalStore.misc().leastActiveLinkedDevice = null + SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis() + return Result.success() + } + + val timeSinceActive = System.currentTimeMillis() - leastActiveDevice.lastActiveTimestamp + Log.i(TAG, "Least active linked device was last active ${timeSinceActive.milliseconds.toDouble(DurationUnit.DAYS).roundedString(2)} days ago ($timeSinceActive ms).") + + SignalStore.misc().hasLinkedDevices = true + SignalStore.misc().leastActiveLinkedDevice = leastActiveDevice + SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis() + + return Result.success() + } + + override fun onFailure() { + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): LinkedDeviceInactiveCheckJob { + return LinkedDeviceInactiveCheckJob(parameters) + } + } +} 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 e283326202..d1aeeec4aa 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.keyvalue.protos.LeastActiveLinkedDevice internal class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { companion object { @@ -32,6 +33,8 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) : private const val NEEDS_USERNAME_RESTORE = "misc.needs_username_restore" private const val LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh" private const val LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync" + private const val LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME = "misc.linked_device.last_active_check_time" + private const val LEAST_ACTIVE_LINKED_DEVICE = "misc.linked_device.least_active" } public override fun onFirstEverAppLaunch() { @@ -223,4 +226,14 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) : * How long it's been since the last foreground CDS sync, which we do in response to new threads being created. */ var lastCdsForegroundSyncTime by longValue(LAST_CDS_FOREGROUND_SYNC, 0) + + /** + * The last time we checked for linked device activity. + */ + var linkedDeviceLastActiveCheckTime by longValue(LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME, 0) + + /** + * Details about the least-active linked device. + */ + var leastActiveLinkedDevice: LeastActiveLinkedDevice? by protoValue(LEAST_ACTIVE_LINKED_DEVICE, LeastActiveLinkedDevice.ADAPTER) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index e96574bf98..fc9e694e08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Stream; import com.bumptech.glide.Glide; -import org.checkerframework.checker.units.qual.A; import org.signal.core.util.MapUtil; import org.signal.core.util.SetUtil; import org.signal.core.util.TranslationDetection; @@ -26,9 +25,9 @@ import org.thoughtcrime.securesms.database.model.MegaphoneRecord; import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice; import org.thoughtcrime.securesms.lock.SignalPinReminderDialog; import org.thoughtcrime.securesms.lock.SignalPinReminders; import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity; @@ -116,6 +115,7 @@ public final class Megaphones { put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER); put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER); put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER); + put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)): NEVER); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER); @@ -125,6 +125,18 @@ public final class Megaphones { }}; } + private static boolean shouldShowLinkedDeviceInactiveMegaphone() { + LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice(); + if (device == null) { + return false; + } + + long expiringAt = device.lastActiveTimestamp + FeatureFlags.linkedDeviceLifespan(); + long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0); + + return expiringIn < TimeUnit.DAYS.toMillis(7) && expiringIn > 0; + } + private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) { switch (record.getEvent()) { case PINS_FOR_ALL: @@ -141,6 +153,8 @@ public final class Megaphones { return buildAddAProfilePhotoMegaphone(context); case TURN_OFF_CENSORSHIP_CIRCUMVENTION: return buildTurnOffCircumventionMegaphone(context); + case LINKED_DEVICE_INACTIVE: + return buildLinkedDeviceInactiveMegaphone(context); case REMOTE_MEGAPHONE: return buildRemoteMegaphone(context); case BACKUP_SCHEDULE_PERMISSION: @@ -156,6 +170,29 @@ public final class Megaphones { } } + private static Megaphone buildLinkedDeviceInactiveMegaphone(Context context) { + LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice(); + if (device == null) { + throw new IllegalStateException("No linked device to show"); + } + + long expiringAt = device.lastActiveTimestamp + TimeUnit.DAYS.toMillis(30); + long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0); + int expiringDays = (int) TimeUnit.MILLISECONDS.toDays(expiringIn); + + return new Megaphone.Builder(Event.LINKED_DEVICE_INACTIVE, Megaphone.Style.BASIC) + .setTitle(R.string.LinkedDeviceInactiveMegaphone_title) + .setBody(context.getResources().getQuantityString(R.plurals.LinkedDeviceInactiveMegaphone_body, expiringDays, device.name, expiringDays)) + .setImage(R.drawable.ic_inactive_linked_device) + .setActionButton(R.string.LinkedDeviceInactiveMegaphone_got_it_button_label, (megaphone, listener) -> { + listener.onMegaphoneSnooze(Event.LINKED_DEVICE_INACTIVE); + }) + .setSecondaryButton(R.string.LinkedDeviceInactiveMegaphone_dont_remind_button_label, (megaphone, listener) -> { + listener.onMegaphoneCompleted(Event.LINKED_DEVICE_INACTIVE); + }) + .build(); + } + private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull MegaphoneRecord record) { if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) { return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN) @@ -481,6 +518,7 @@ public final class Megaphones { DONATE_Q2_2022("donate_q2_2022"), TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"), REMOTE_MEGAPHONE("remote_megaphone"), + LINKED_DEVICE_INACTIVE("linked_device_inactive"), BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"), SET_UP_YOUR_USERNAME("set_up_your_username"), PNP_LAUNCH("pnp_launch"), diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt index b0b48a4914..74b28b01f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt @@ -1,11 +1,17 @@ package org.thoughtcrime.securesms.registration.secondary import okio.ByteString.Companion.toByteString +import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.util.ByteUtil import org.thoughtcrime.securesms.devicelist.protos.DeviceName import java.nio.charset.Charset +import java.security.GeneralSecurityException +import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec @@ -16,6 +22,8 @@ import javax.crypto.spec.SecretKeySpec */ object DeviceNameCipher { + private val TAG = Log.tag(DeviceNameCipher::class.java) + private const val SYNTHETIC_IV_LENGTH = 16 @JvmStatic @@ -37,6 +45,54 @@ object DeviceNameCipher { ).encode() } + /** + * Decrypts a [DeviceName]. Returns null if data is invalid/undecryptable. + */ + @JvmStatic + fun decryptDeviceName(deviceName: DeviceName, identityKeyPair: IdentityKeyPair): ByteArray? { + if (deviceName.ephemeralPublic == null || deviceName.syntheticIv == null || deviceName.ciphertext == null) { + return null + } + + return try { + val syntheticIv = deviceName.syntheticIv.toByteArray() + val cipherText = deviceName.ciphertext.toByteArray() + val identityKey: ECPrivateKey = identityKeyPair.privateKey + val ephemeralPublic = Curve.decodePoint(deviceName.ephemeralPublic.toByteArray(), 0) + val masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey) + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val cipherKeyPart1 = mac.doFinal("cipher".toByteArray()) + + mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256")) + val cipherKey = mac.doFinal(syntheticIv) + + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + val plaintext = cipher.doFinal(cipherText) + + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val verificationPart1 = mac.doFinal("auth".toByteArray()) + + mac.init(SecretKeySpec(verificationPart1, "HmacSHA256")) + val verificationPart2 = mac.doFinal(plaintext) + val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16) + + if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { + throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.") + } + + plaintext + } catch (e: GeneralSecurityException) { + Log.w(TAG, "Failed to decrypt device name.", e) + null + } catch (e: InvalidKeyException) { + Log.w(TAG, "Failed to decrypt device name.", e) + null + } + } + private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray { val input = "cipher".toByteArray(Charset.forName("UTF-8")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index f99f6cda46..b5ae59104d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -125,6 +125,7 @@ public final class FeatureFlags { private static final String PREKEY_FORCE_REFRESH_INTERVAL = "android.prekeyForceRefreshInterval"; private static final String CDSI_LIBSIGNAL_NET = "android.cds.libsignal.2"; private static final String RX_MESSAGE_SEND = "android.rxMessageSend"; + private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -202,7 +203,8 @@ public final class FeatureFlags { RETRY_RECEIPT_MAX_COUNT_RESET_AGE, PREKEY_FORCE_REFRESH_INTERVAL, CDSI_LIBSIGNAL_NET, - RX_MESSAGE_SEND + RX_MESSAGE_SEND, + LINKED_DEVICE_LIFESPAN_SECONDS ); @VisibleForTesting @@ -277,7 +279,8 @@ public final class FeatureFlags { RETRY_RECEIPT_MAX_COUNT_RESET_AGE, PREKEY_FORCE_REFRESH_INTERVAL, CDSI_LIBSIGNAL_NET, - RX_MESSAGE_SEND + RX_MESSAGE_SEND, + LINKED_DEVICE_LIFESPAN_SECONDS ); /** @@ -722,6 +725,12 @@ public final class FeatureFlags { return getBoolean(RX_MESSAGE_SEND, false); } + /** The lifespan of a linked device (i.e. the time it can be inactive for before it expires), in milliseconds. */ + public static long linkedDeviceLifespan() { + long seconds = getLong(LINKED_DEVICE_LIFESPAN_SECONDS, TimeUnit.DAYS.toSeconds(30)); + return TimeUnit.SECONDS.toMillis(seconds); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/protowire/KeyValue.proto b/app/src/main/protowire/KeyValue.proto new file mode 100644 index 0000000000..2a0c52088f --- /dev/null +++ b/app/src/main/protowire/KeyValue.proto @@ -0,0 +1,17 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.keyvalue.protos"; + + +message LeastActiveLinkedDevice { + string name = 1; + uint64 lastActiveTimestamp = 2; +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_inactive_linked_device.xml b/app/src/main/res/drawable/ic_inactive_linked_device.xml new file mode 100644 index 0000000000..82db92cf8a --- /dev/null +++ b/app/src/main/res/drawable/ic_inactive_linked_device.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index add02626fd..a22bb87400 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6664,5 +6664,17 @@ Encountered a network error. Try again later. + + Inactive linked device + + + To keep \"%1$s\" linked, open Signal on that device within %2$d day. + To keep \"%1$s\" linked, open Signal on that device within %2$d days. + + + Don\'t remind me + + Got it + diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt index 9e9cad9706..2166cabe53 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt @@ -4,7 +4,6 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.junit.Test import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.database.loaders.DeviceListLoader import org.thoughtcrime.securesms.devicelist.protos.DeviceName import java.nio.charset.Charset @@ -17,7 +16,7 @@ class DeviceNameCipherTest { val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(Charset.forName("UTF-8")), identityKeyPair) - val plaintext = DeviceListLoader.decryptName(DeviceName.ADAPTER.decode(encryptedDeviceName), identityKeyPair) + val plaintext = DeviceNameCipher.decryptDeviceName(DeviceName.ADAPTER.decode(encryptedDeviceName), identityKeyPair)!! assertThat(String(plaintext, Charset.forName("UTF-8")), `is`(deviceName)) }