mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 02:08:40 +00:00
Show a megaphone when a device is about to unlink.
This commit is contained in:
committed by
Nicholas Tinsley
parent
d7ee9639fd
commit
50149a3803
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<List<Device>> {
|
||||
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<Device> {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<LinkedDeviceInactiveCheckJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): LinkedDeviceInactiveCheckJob {
|
||||
return LinkedDeviceInactiveCheckJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
||||
Reference in New Issue
Block a user