]> nmode's Git Repositories - signal-cli/commitdiff
Implement change phone number
authorAsamK <asamk@gmx.de>
Thu, 12 Oct 2023 19:15:00 +0000 (21:15 +0200)
committerAsamK <asamk@gmx.de>
Sat, 14 Oct 2023 21:37:23 +0000 (23:37 +0200)
Closes #1240

15 files changed:
graalvm-config-dir/reflect-config.json
lib/src/main/java/org/asamk/signal/manager/Manager.java
lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java
lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java
lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java
lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java
lib/src/main/java/org/asamk/signal/manager/util/Utils.java
src/main/java/org/asamk/signal/commands/Commands.java
src/main/java/org/asamk/signal/commands/FinishChangeNumberCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/commands/RegisterCommand.java
src/main/java/org/asamk/signal/commands/StartChangeNumberCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java
src/main/java/org/asamk/signal/util/CommandUtil.java

index 1af2fbf047d7d463471e34a5352d343382e77dd2..d24416d9ae2d6500e4502737a292ca839678439e 100644 (file)
   "name":"java.util.Locale",
   "methods":[{"name":"getUnicodeLocaleType","parameterTypes":["java.lang.String"] }]
 },
+{
+  "name":"java.util.Map"
+},
 {
   "name":"java.util.Optional",
   "allDeclaredFields":true,
 {
   "name":"kotlin.collections.List"
 },
+{
+  "name":"kotlin.collections.Map"
+},
 {
   "name":"kotlin.collections.MutableList"
 },
+{
+  "name":"kotlin.collections.MutableMap"
+},
 {
   "name":"kotlin.jvm.JvmStatic",
   "queryAllDeclaredMethods":true
   "name":"org.asamk.Signal",
   "allDeclaredMethods":true,
   "allDeclaredClasses":true,
-  "methods":[{"name":"getSelfNumber","parameterTypes":[] }, {"name":"sendGroupMessageReaction","parameterTypes":["java.lang.String","boolean","java.lang.String","long","byte[]"] }, {"name":"sendMessage","parameterTypes":["java.lang.String","java.util.List","java.lang.String"] }, {"name":"sendMessageReaction","parameterTypes":["java.lang.String","boolean","java.lang.String","long","java.util.List"] }, {"name":"subscribeReceive","parameterTypes":[] }, {"name":"unsubscribeReceive","parameterTypes":[] }]
+  "methods":[{"name":"getContactName","parameterTypes":["java.lang.String"] }, {"name":"getSelfNumber","parameterTypes":[] }, {"name":"sendGroupMessageReaction","parameterTypes":["java.lang.String","boolean","java.lang.String","long","byte[]"] }, {"name":"sendMessage","parameterTypes":["java.lang.String","java.util.List","java.lang.String"] }, {"name":"sendMessageReaction","parameterTypes":["java.lang.String","boolean","java.lang.String","long","java.util.List"] }, {"name":"subscribeReceive","parameterTypes":[] }, {"name":"unsubscribeReceive","parameterTypes":[] }]
 },
 {
   "name":"org.asamk.Signal$Configuration",
 {
   "name":"org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest",
   "allDeclaredFields":true,
+  "allDeclaredClasses":true,
   "queryAllDeclaredMethods":true,
   "queryAllDeclaredConstructors":true,
-  "methods":[{"name":"getNumber","parameterTypes":[] }, {"name":"getRegistrationLock","parameterTypes":[] }]
+  "methods":[{"name":"getDeviceMessages","parameterTypes":[] }, {"name":"getDevicePniSignedPrekeys","parameterTypes":[] }, {"name":"getNumber","parameterTypes":[] }, {"name":"getPniIdentityKey","parameterTypes":[] }, {"name":"getPniRegistrationIds","parameterTypes":[] }, {"name":"getRegistrationLock","parameterTypes":[] }]
 },
 {
   "name":"org.whispersystems.signalservice.api.groupsv2.CredentialResponse",
index 264922a83f5f1c035594f610f2301cd9ffd72177..7cba24f8fd067eedb050bc00786846f08d191d6f 100644 (file)
@@ -2,6 +2,7 @@ package org.asamk.signal.manager;
 
 import org.asamk.signal.manager.api.AlreadyReceivingException;
 import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.Configuration;
 import org.asamk.signal.manager.api.Device;
 import org.asamk.signal.manager.api.DeviceLinkUrl;
@@ -13,16 +14,20 @@ import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.api.Identity;
 import org.asamk.signal.manager.api.IdentityVerificationCode;
 import org.asamk.signal.manager.api.InactiveGroupLinkException;
+import org.asamk.signal.manager.api.IncorrectPinException;
 import org.asamk.signal.manager.api.InvalidDeviceLinkException;
 import org.asamk.signal.manager.api.InvalidStickerException;
 import org.asamk.signal.manager.api.InvalidUsernameException;
 import org.asamk.signal.manager.api.LastGroupAdminException;
 import org.asamk.signal.manager.api.Message;
 import org.asamk.signal.manager.api.MessageEnvelope;
+import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
 import org.asamk.signal.manager.api.NotAGroupMemberException;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
 import org.asamk.signal.manager.api.Pair;
 import org.asamk.signal.manager.api.PendingAdminApprovalException;
+import org.asamk.signal.manager.api.PinLockedException;
+import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.manager.api.ReceiveConfig;
 import org.asamk.signal.manager.api.Recipient;
 import org.asamk.signal.manager.api.RecipientIdentifier;
@@ -107,6 +112,14 @@ public interface Manager extends Closeable {
      */
     void deleteUsername() throws IOException;
 
+    void startChangeNumber(
+            String newNumber, boolean voiceVerification, String captcha
+    ) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException;
+
+    void finishChangeNumber(
+            String newNumber, String verificationCode, String pin
+    ) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException;
+
     void unregister() throws IOException;
 
     void deleteAccount() throws IOException;
index 5ff11b34fd4a99d3a812ff92dfd9f96ee80f4b9f..e021cc37c4b10de6b67268d519fe6fd610a604c5 100644 (file)
@@ -14,16 +14,20 @@ import org.asamk.signal.manager.util.NumberVerificationUtils;
 import org.asamk.signal.manager.util.Utils;
 import org.signal.libsignal.protocol.IdentityKeyPair;
 import org.signal.libsignal.protocol.InvalidKeyException;
+import org.signal.libsignal.protocol.SignalProtocolAddress;
 import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
 import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
+import org.signal.libsignal.protocol.util.KeyHelper;
 import org.signal.libsignal.usernames.BaseUsernameException;
 import org.signal.libsignal.usernames.Username;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
+import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
 import org.whispersystems.signalservice.api.push.ServiceId.PNI;
 import org.whispersystems.signalservice.api.push.ServiceIdType;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
 import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
 import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
@@ -31,16 +35,21 @@ import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionExc
 import org.whispersystems.signalservice.api.util.DeviceNameUtil;
 import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
 import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
+import org.whispersystems.signalservice.internal.push.SyncMessage;
+import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
 import org.whispersystems.util.Base64UrlSafe;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
+import okio.ByteString;
+
+import static org.asamk.signal.manager.config.ServiceConfig.PREKEY_MAXIMUM_ID;
 import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
 
 public class AccountHelper {
@@ -139,7 +148,7 @@ public class AccountHelper {
     }
 
     public void startChangeNumber(
-            String newNumber, String captcha, boolean voiceVerification
+            String newNumber, boolean voiceVerification, String captcha
     ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException {
         final var accountManager = dependencies.createUnauthenticatedAccountManager(newNumber, account.getPassword());
         String sessionId = NumberVerificationUtils.handleVerificationSession(accountManager,
@@ -153,12 +162,92 @@ public class AccountHelper {
     public void finishChangeNumber(
             String newNumber, String verificationCode, String pin
     ) throws IncorrectPinException, PinLockedException, IOException {
-        // TODO create new PNI identity key
-        final List<OutgoingPushMessage> deviceMessages = null;
-        final Map<String, SignedPreKeyEntity> devicePniSignedPreKeys = null;
-        final Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys = null;
-        final Map<String, Integer> pniRegistrationIds = null;
-        var sessionId = account.getSessionId(account.getNumber());
+        for (var attempts = 0; attempts < 5; attempts++) {
+            try {
+                finishChangeNumberInternal(newNumber, verificationCode, pin);
+                break;
+            } catch (MismatchedDevicesException e) {
+                logger.debug("Change number failed with mismatched devices, retrying.");
+                try {
+                    dependencies.getMessageSender().handleChangeNumberMismatchDevices(e.getMismatchedDevices());
+                } catch (UntrustedIdentityException ex) {
+                    throw new AssertionError(ex);
+                }
+            }
+        }
+    }
+
+    private void finishChangeNumberInternal(
+            String newNumber, String verificationCode, String pin
+    ) throws IncorrectPinException, PinLockedException, IOException {
+        final var pniIdentity = KeyUtils.generateIdentityKeyPair();
+        final var encryptedDeviceMessages = new ArrayList<OutgoingPushMessage>();
+        final var devicePniSignedPreKeys = new HashMap<Integer, SignedPreKeyEntity>();
+        final var devicePniLastResortKyberPreKeys = new HashMap<Integer, KyberPreKeyEntity>();
+        final var pniRegistrationIds = new HashMap<Integer, Integer>();
+
+        final var selfDeviceId = account.getDeviceId();
+        SyncMessage.PniChangeNumber selfChangeNumber = null;
+
+        final var deviceIds = new ArrayList<Integer>();
+        deviceIds.add(SignalServiceAddress.DEFAULT_DEVICE_ID);
+        final var aci = account.getAci();
+        final var accountDataStore = account.getSignalServiceDataStore().aci();
+        final var subDeviceSessions = accountDataStore.getSubDeviceSessions(aci.toString())
+                .stream()
+                .filter(deviceId -> accountDataStore.containsSession(new SignalProtocolAddress(aci.toString(),
+                        deviceId)))
+                .toList();
+        deviceIds.addAll(subDeviceSessions);
+
+        final var messageSender = dependencies.getMessageSender();
+        for (final var deviceId : deviceIds) {
+            // Signed Prekey
+            final var signedPreKeyRecord = KeyUtils.generateSignedPreKeyRecord(KeyUtils.getRandomInt(PREKEY_MAXIMUM_ID),
+                    pniIdentity.getPrivateKey());
+            final var signedPreKeyEntity = new SignedPreKeyEntity(signedPreKeyRecord.getId(),
+                    signedPreKeyRecord.getKeyPair().getPublicKey(),
+                    signedPreKeyRecord.getSignature());
+            devicePniSignedPreKeys.put(deviceId, signedPreKeyEntity);
+
+            // Last-resort kyber prekey
+            final var lastResortKyberPreKeyRecord = KeyUtils.generateKyberPreKeyRecord(KeyUtils.getRandomInt(
+                    PREKEY_MAXIMUM_ID), pniIdentity.getPrivateKey());
+            final var kyberPreKeyEntity = new KyberPreKeyEntity(lastResortKyberPreKeyRecord.getId(),
+                    lastResortKyberPreKeyRecord.getKeyPair().getPublicKey(),
+                    lastResortKyberPreKeyRecord.getSignature());
+            devicePniLastResortKyberPreKeys.put(deviceId, kyberPreKeyEntity);
+
+            // Registration Id
+            var pniRegistrationId = -1;
+            while (pniRegistrationId < 0 || pniRegistrationIds.containsValue(pniRegistrationId)) {
+                pniRegistrationId = KeyHelper.generateRegistrationId(false);
+            }
+            pniRegistrationIds.put(deviceId, pniRegistrationId);
+
+            // Device Message
+            final var pniChangeNumber = new SyncMessage.PniChangeNumber.Builder().identityKeyPair(ByteString.of(
+                            pniIdentity.serialize()))
+                    .signedPreKey(ByteString.of(signedPreKeyRecord.serialize()))
+                    .lastResortKyberPreKey(ByteString.of(lastResortKyberPreKeyRecord.serialize()))
+                    .registrationId(pniRegistrationId)
+                    .newE164(newNumber)
+                    .build();
+
+            if (deviceId == selfDeviceId) {
+                selfChangeNumber = pniChangeNumber;
+            } else {
+                try {
+                    final var message = messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId,
+                            pniChangeNumber);
+                    encryptedDeviceMessages.add(message);
+                } catch (UntrustedIdentityException | IOException | InvalidKeyException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }
+
+        final var sessionId = account.getSessionId(newNumber);
         final var result = NumberVerificationUtils.verifyNumber(sessionId,
                 verificationCode,
                 pin,
@@ -166,7 +255,7 @@ public class AccountHelper {
                 (sessionId1, verificationCode1, registrationLock) -> {
                     final var accountManager = dependencies.getAccountManager();
                     try {
-                        Utils.handleResponseException(accountManager.verifyAccount(verificationCode, sessionId1));
+                        Utils.handleResponseException(accountManager.verifyAccount(verificationCode1, sessionId1));
                     } catch (AlreadyVerifiedException e) {
                         // Already verified so can continue changing number
                     }
@@ -175,14 +264,42 @@ public class AccountHelper {
                             null,
                             newNumber,
                             registrationLock,
-                            account.getPniIdentityKeyPair().getPublicKey(),
-                            deviceMessages,
-                            devicePniSignedPreKeys,
-                            devicePniLastResortKyberPrekeys,
-                            pniRegistrationIds)));
+                            pniIdentity.getPublicKey(),
+                            encryptedDeviceMessages,
+                            Utils.mapKeys(devicePniSignedPreKeys, Object::toString),
+                            Utils.mapKeys(devicePniLastResortKyberPreKeys, Object::toString),
+                            Utils.mapKeys(pniRegistrationIds, Object::toString))));
                 });
-        // TODO handle response
-        updateSelfIdentifiers(newNumber, account.getAci(), PNI.parseOrThrow(result.first().getPni()));
+
+        final var updatePni = PNI.parseOrThrow(result.first().getPni());
+        if (updatePni.equals(account.getPni())) {
+            logger.debug("PNI is unchanged after change number");
+            return;
+        }
+
+        handlePniChangeNumberMessage(selfChangeNumber, updatePni);
+    }
+
+    public void handlePniChangeNumberMessage(
+            final SyncMessage.PniChangeNumber pniChangeNumber, final PNI updatedPni
+    ) {
+        if (pniChangeNumber.identityKeyPair != null
+                && pniChangeNumber.registrationId != null
+                && pniChangeNumber.signedPreKey != null) {
+            logger.debug("New PNI: {}", updatedPni);
+            try {
+                setPni(updatedPni,
+                        new IdentityKeyPair(pniChangeNumber.identityKeyPair.toByteArray()),
+                        pniChangeNumber.newE164,
+                        pniChangeNumber.registrationId,
+                        new SignedPreKeyRecord(pniChangeNumber.signedPreKey.toByteArray()),
+                        pniChangeNumber.lastResortKyberPreKey != null
+                                ? new KyberPreKeyRecord(pniChangeNumber.lastResortKyberPreKey.toByteArray())
+                                : null);
+            } catch (Exception e) {
+                logger.warn("Failed to handle change number message", e);
+            }
+        }
     }
 
     public static final int USERNAME_MIN_LENGTH = 3;
index 9d69632ecc81274e6ae67708d684c1fba82520f6..3f0abc4812c947577ca2e0f0536805ad3a370ea0 100644 (file)
@@ -40,12 +40,9 @@ import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
 import org.signal.libsignal.metadata.ProtocolNoSessionException;
 import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
 import org.signal.libsignal.metadata.SelfSendException;
-import org.signal.libsignal.protocol.IdentityKeyPair;
 import org.signal.libsignal.protocol.InvalidMessageException;
 import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
 import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
-import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
-import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
 import org.signal.libsignal.zkgroup.InvalidInputException;
 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
 import org.slf4j.Logger;
@@ -67,7 +64,6 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
 import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
 import org.whispersystems.signalservice.api.push.ServiceId;
 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
-import org.whispersystems.signalservice.api.push.ServiceId.PNI;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 import org.whispersystems.signalservice.internal.push.Envelope;
 import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
@@ -618,24 +614,10 @@ public final class IncomingMessageHandler {
         if (syncMessage.getPniChangeNumber().isPresent()) {
             final var pniChangeNumber = syncMessage.getPniChangeNumber().get();
             logger.debug("Received PNI change number sync message, applying.");
-            if (pniChangeNumber.identityKeyPair != null
-                    && pniChangeNumber.registrationId != null
-                    && pniChangeNumber.signedPreKey != null
-                    && !envelope.getUpdatedPni().isEmpty()) {
-                logger.debug("New PNI: {}", envelope.getUpdatedPni());
-                try {
-                    final var updatedPni = PNI.parseOrThrow(envelope.getUpdatedPni());
-                    context.getAccountHelper()
-                            .setPni(updatedPni,
-                                    new IdentityKeyPair(pniChangeNumber.identityKeyPair.toByteArray()),
-                                    pniChangeNumber.newE164,
-                                    pniChangeNumber.registrationId,
-                                    new SignedPreKeyRecord(pniChangeNumber.signedPreKey.toByteArray()),
-                                    pniChangeNumber.lastResortKyberPreKey != null ? new KyberPreKeyRecord(
-                                            pniChangeNumber.lastResortKyberPreKey.toByteArray()) : null);
-                } catch (Exception e) {
-                    logger.warn("Failed to handle change number message", e);
-                }
+            final var updatedPniString = envelope.getUpdatedPni();
+            if (updatedPniString != null && !updatedPniString.isEmpty()) {
+                final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
+                context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni);
             }
         }
         return actions;
index 1961fc560fecd685a180755230b91e3727af036c..c08da92c7efc583784a56a4a0d7229dcfd14ee87 100644 (file)
@@ -112,7 +112,12 @@ public class PreKeyHelper {
                     preKeyRecords,
                     lastResortKyberPreKeyRecord,
                     kyberPreKeyRecords);
-            dependencies.getAccountManager().setPreKeys(preKeyUpload);
+            try {
+                dependencies.getAccountManager().setPreKeys(preKeyUpload);
+            } catch (AuthorizationFailedException e) {
+                // This can happen when the primary device has changed phone number
+                logger.warn("Failed to updated pre keys: {}", e.getMessage());
+            }
         }
 
         cleanSignedPreKeys((serviceIdType));
index bfb52ebfd13aa013ce1efbe8d6e1623f6dfc488d..a91a60996e3efdb6b2f3e2711ba189ada8233bf7 100644 (file)
@@ -19,6 +19,7 @@ package org.asamk.signal.manager.internal;
 import org.asamk.signal.manager.Manager;
 import org.asamk.signal.manager.api.AlreadyReceivingException;
 import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.Configuration;
 import org.asamk.signal.manager.api.Device;
 import org.asamk.signal.manager.api.DeviceLinkUrl;
@@ -30,17 +31,21 @@ import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.api.Identity;
 import org.asamk.signal.manager.api.IdentityVerificationCode;
 import org.asamk.signal.manager.api.InactiveGroupLinkException;
+import org.asamk.signal.manager.api.IncorrectPinException;
 import org.asamk.signal.manager.api.InvalidDeviceLinkException;
 import org.asamk.signal.manager.api.InvalidStickerException;
 import org.asamk.signal.manager.api.InvalidUsernameException;
 import org.asamk.signal.manager.api.LastGroupAdminException;
 import org.asamk.signal.manager.api.Message;
 import org.asamk.signal.manager.api.MessageEnvelope;
+import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
 import org.asamk.signal.manager.api.NotAGroupMemberException;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
 import org.asamk.signal.manager.api.Pair;
 import org.asamk.signal.manager.api.PendingAdminApprovalException;
+import org.asamk.signal.manager.api.PinLockedException;
 import org.asamk.signal.manager.api.Profile;
+import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.manager.api.ReceiveConfig;
 import org.asamk.signal.manager.api.Recipient;
 import org.asamk.signal.manager.api.RecipientIdentifier;
@@ -317,6 +322,26 @@ public class ManagerImpl implements Manager {
         context.getAccountHelper().deleteUsername();
     }
 
+    @Override
+    public void startChangeNumber(
+            String newNumber, boolean voiceVerification, String captcha
+    ) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException {
+        if (!account.isPrimaryDevice()) {
+            throw new NotPrimaryDeviceException();
+        }
+        context.getAccountHelper().startChangeNumber(newNumber, voiceVerification, captcha);
+    }
+
+    @Override
+    public void finishChangeNumber(
+            String newNumber, String verificationCode, String pin
+    ) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException {
+        if (!account.isPrimaryDevice()) {
+            throw new NotPrimaryDeviceException();
+        }
+        context.getAccountHelper().finishChangeNumber(newNumber, verificationCode, pin);
+    }
+
     @Override
     public void unregister() throws IOException {
         context.getAccountHelper().unregister();
index a7954ca2b663510d398b5476d783949eafd5ec27..6b63920e4c67828e59bc25c5a724d03a43745f13 100644 (file)
@@ -1383,7 +1383,6 @@ public class SignalAccount implements Closeable {
         if (oldPni != null && !oldPni.equals(updatedPni)) {
             // Clear data for old PNI
             identityKeyStore.deleteIdentity(oldPni);
-            clearAllPreKeys(ServiceIdType.PNI);
         }
 
         this.pniAccountData.setServiceId(updatedPni);
@@ -1400,11 +1399,14 @@ public class SignalAccount implements Closeable {
         setPniIdentityKeyPair(pniIdentityKeyPair);
         pniAccountData.setLocalRegistrationId(localPniRegistrationId);
 
-        final var preKeyMetadata = getAccountData(ServiceIdType.PNI).getPreKeyMetadata();
+        final AccountData<? extends ServiceId> accountData = getAccountData(ServiceIdType.PNI);
+        final var preKeyMetadata = accountData.getPreKeyMetadata();
         preKeyMetadata.nextSignedPreKeyId = pniSignedPreKey.getId();
+        accountData.getSignedPreKeyStore().removeSignedPreKey(pniSignedPreKey.getId());
         addSignedPreKey(ServiceIdType.PNI, pniSignedPreKey);
         if (lastResortKyberPreKey != null) {
             preKeyMetadata.nextKyberPreKeyId = lastResortKyberPreKey.getId();
+            accountData.getKyberPreKeyStore().removeKyberPreKey(lastResortKyberPreKey.getId());
             addLastResortKyberPreKey(ServiceIdType.PNI, lastResortKyberPreKey);
         }
         save();
index f8a14c0544ed2effb96f1623aa829212965afce0..62474e665460dec7227c800c42bcf8f07e3e49f6 100644 (file)
@@ -7,6 +7,8 @@ import org.asamk.signal.manager.api.Pair;
 import org.asamk.signal.manager.api.PinLockedException;
 import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.manager.helper.PinHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.whispersystems.signalservice.api.SignalServiceAccountManager;
 import org.whispersystems.signalservice.api.kbs.MasterKey;
 import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException;
@@ -24,6 +26,8 @@ import java.util.function.Consumer;
 
 public class NumberVerificationUtils {
 
+    private final static Logger logger = LoggerFactory.getLogger(NumberVerificationUtils.class);
+
     public static String handleVerificationSession(
             SignalServiceAccountManager accountManager,
             String sessionId,
@@ -143,7 +147,7 @@ public class NumberVerificationUtils {
 
     private static RegistrationSessionMetadataResponse requestValidSession(
             final SignalServiceAccountManager accountManager
-    ) throws NoSuchSessionException, IOException {
+    ) throws IOException {
         return Utils.handleResponseException(accountManager.createRegistrationSession(null, "", ""));
     }
 
@@ -153,6 +157,7 @@ public class NumberVerificationUtils {
         try {
             return validateSession(accountManager, sessionId);
         } catch (NoSuchSessionException e) {
+            logger.debug("No registration session, creating new one.");
             return requestValidSession(accountManager);
         }
     }
index 16e80e453f663b697e907190817ff04fb2db8e45..6e9c701e13cddf4b401cb76bb3ca98fbdfdaa143 100644 (file)
@@ -25,6 +25,8 @@ import java.util.Spliterator;
 import java.util.Spliterators;
 import java.util.function.BiFunction;
 import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 
@@ -122,6 +124,10 @@ public class Utils {
         }, leftStream.isParallel() || rightStream.isParallel());
     }
 
+    public static <OK, NK, V> Map<NK, V> mapKeys(Map<OK, V> map, Function<OK, NK> keyMapper) {
+        return map.entrySet().stream().collect(Collectors.toMap(e -> keyMapper.apply(e.getKey()), Map.Entry::getValue));
+    }
+
     public static Map<String, String> getQueryMap(String query) {
         var params = query.split("&");
         var map = new HashMap<String, String>();
index 2783ca3748cde9801d8c5cb7cc4f9a00b6ec57ff..719ba132ac681f4857b6fb87c09e7bcf658fdcbd 100644 (file)
@@ -14,6 +14,7 @@ public class Commands {
         addCommand(new BlockCommand());
         addCommand(new DaemonCommand());
         addCommand(new DeleteLocalAccountDataCommand());
+        addCommand(new FinishChangeNumberCommand());
         addCommand(new FinishLinkCommand());
         addCommand(new GetAttachmentCommand());
         addCommand(new GetUserStatusCommand());
@@ -43,6 +44,7 @@ public class Commands {
         addCommand(new SendTypingCommand());
         addCommand(new SetPinCommand());
         addCommand(new SubmitRateLimitChallengeCommand());
+        addCommand(new StartChangeNumberCommand());
         addCommand(new StartLinkCommand());
         addCommand(new TrustCommand());
         addCommand(new UnblockCommand());
diff --git a/src/main/java/org/asamk/signal/commands/FinishChangeNumberCommand.java b/src/main/java/org/asamk/signal/commands/FinishChangeNumberCommand.java
new file mode 100644 (file)
index 0000000..c2c18dd
--- /dev/null
@@ -0,0 +1,58 @@
+package org.asamk.signal.commands;
+
+import net.sourceforge.argparse4j.inf.Namespace;
+import net.sourceforge.argparse4j.inf.Subparser;
+
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.commands.exceptions.IOErrorException;
+import org.asamk.signal.commands.exceptions.UserErrorException;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.IncorrectPinException;
+import org.asamk.signal.manager.api.NotPrimaryDeviceException;
+import org.asamk.signal.manager.api.PinLockedException;
+import org.asamk.signal.output.OutputWriter;
+
+import java.io.IOException;
+
+public class FinishChangeNumberCommand implements JsonRpcLocalCommand {
+
+    @Override
+    public String getName() {
+        return "finishChangeNumber";
+    }
+
+    @Override
+    public void attachToSubparser(final Subparser subparser) {
+        subparser.help("Verify the new number using the code received via SMS or voice.");
+        subparser.addArgument("number").help("The new phone number in E164 format.").required(true);
+        subparser.addArgument("-v", "--verification-code")
+                .help("The verification code you received via sms or voice call.")
+                .required(true);
+        subparser.addArgument("-p", "--pin").help("The registration lock PIN, that was set by the user (Optional)");
+    }
+
+    @Override
+    public void handleCommand(
+            final Namespace ns, final Manager m, final OutputWriter outputWriter
+    ) throws CommandException {
+        final var newNumber = ns.getString("number");
+        final var verificationCode = ns.getString("verification-code");
+        final var pin = ns.getString("pin");
+
+        try {
+            m.finishChangeNumber(newNumber, verificationCode, pin);
+        } catch (PinLockedException e) {
+            throw new UserErrorException(
+                    "Verification failed! This number is locked with a pin. Hours remaining until reset: "
+                            + (e.getTimeRemaining() / 1000 / 60 / 60)
+                            + "\nUse '--pin PIN_CODE' to specify the registration lock PIN");
+        } catch (IncorrectPinException e) {
+            throw new UserErrorException("Verification failed! Invalid pin, tries remaining: " + e.getTriesRemaining());
+        } catch (NotPrimaryDeviceException e) {
+            throw new UserErrorException("This command doesn't work on linked devices.");
+        } catch (IOException e) {
+            throw new IOErrorException("Failed to change number: %s (%s)".formatted(e.getMessage(),
+                    e.getClass().getSimpleName()), e);
+        }
+    }
+}
index 1ca7bee2c1e20c94e54b086585f2280ee7337708..876d1a8a9954d03f3e098ec2a3e87b4463fbf1f7 100644 (file)
@@ -16,7 +16,7 @@ import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
 import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.output.JsonWriter;
-import org.asamk.signal.util.DateUtils;
+import org.asamk.signal.util.CommandUtil;
 
 import java.io.IOException;
 import java.util.List;
@@ -69,26 +69,10 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
         try {
             m.register(voiceVerification, captcha);
         } catch (RateLimitException e) {
-            String message = "Rate limit reached";
-            if (e.getNextAttemptTimestamp() > 0) {
-                message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
-            }
+            final var message = CommandUtil.getRateLimitMessage(e);
             throw new RateLimitErrorException(message, e);
         } catch (CaptchaRequiredException e) {
-            String message;
-            if (captcha == null) {
-                message = """
-                          Captcha required for verification, use --captcha CAPTCHA
-                          To get the token, go to https://signalcaptchas.org/registration/generate.html
-                          Check the developer tools (F12) console for a failed redirect to signalcaptcha://
-                          Everything after signalcaptcha:// is the captcha token.""";
-            } else {
-                message = "Invalid captcha given.";
-            }
-            if (e.getNextAttemptTimestamp() > 0) {
-                message += "\nNext Captcha may be provided at "
-                        + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
-            }
+            final var message = CommandUtil.getCaptchaRequiredMessage(e, captcha != null);
             throw new UserErrorException(message);
         } catch (NonNormalizedPhoneNumberException e) {
             throw new UserErrorException("Failed to register: " + e.getMessage(), e);
diff --git a/src/main/java/org/asamk/signal/commands/StartChangeNumberCommand.java b/src/main/java/org/asamk/signal/commands/StartChangeNumberCommand.java
new file mode 100644 (file)
index 0000000..7b18dbf
--- /dev/null
@@ -0,0 +1,64 @@
+package org.asamk.signal.commands;
+
+import net.sourceforge.argparse4j.impl.Arguments;
+import net.sourceforge.argparse4j.inf.Namespace;
+import net.sourceforge.argparse4j.inf.Subparser;
+
+import org.asamk.signal.commands.exceptions.CommandException;
+import org.asamk.signal.commands.exceptions.IOErrorException;
+import org.asamk.signal.commands.exceptions.RateLimitErrorException;
+import org.asamk.signal.commands.exceptions.UserErrorException;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.CaptchaRequiredException;
+import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
+import org.asamk.signal.manager.api.NotPrimaryDeviceException;
+import org.asamk.signal.manager.api.RateLimitException;
+import org.asamk.signal.output.OutputWriter;
+import org.asamk.signal.util.CommandUtil;
+
+import java.io.IOException;
+
+public class StartChangeNumberCommand implements JsonRpcLocalCommand {
+
+    @Override
+    public String getName() {
+        return "startChangeNumber";
+    }
+
+    @Override
+    public void attachToSubparser(final Subparser subparser) {
+        subparser.help("Change account to a new phone number with SMS or voice verification.");
+        subparser.addArgument("number").help("The new phone number in E164 format.").required(true);
+        subparser.addArgument("-v", "--voice")
+                .help("The verification should be done over voice, not SMS.")
+                .action(Arguments.storeTrue());
+        subparser.addArgument("--captcha")
+                .help("The captcha token, required if change number failed with a captcha required error.");
+    }
+
+    @Override
+    public void handleCommand(
+            final Namespace ns, final Manager m, final OutputWriter outputWriter
+    ) throws CommandException {
+        final var newNumber = ns.getString("number");
+        final var voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
+        final var captcha = ns.getString("captcha");
+
+        try {
+            m.startChangeNumber(newNumber, voiceVerification, captcha);
+        } catch (RateLimitException e) {
+            final var message = CommandUtil.getRateLimitMessage(e);
+            throw new RateLimitErrorException(message, e);
+        } catch (CaptchaRequiredException e) {
+            final var message = CommandUtil.getCaptchaRequiredMessage(e, captcha != null);
+            throw new UserErrorException(message);
+        } catch (NonNormalizedPhoneNumberException e) {
+            throw new UserErrorException("Failed to change number: " + e.getMessage(), e);
+        } catch (NotPrimaryDeviceException e) {
+            throw new UserErrorException("This command doesn't work on linked devices.");
+        } catch (IOException e) {
+            throw new IOErrorException("Failed to change number: %s (%s)".formatted(e.getMessage(),
+                    e.getClass().getSimpleName()), e);
+        }
+    }
+}
index d6cd52fa19739b5379bcbb5483dea24a7557e5a9..2e30417c58795ec280b0f4e82415aaacb0157222 100644 (file)
@@ -4,6 +4,7 @@ import org.asamk.Signal;
 import org.asamk.signal.DbusConfig;
 import org.asamk.signal.manager.Manager;
 import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.Configuration;
 import org.asamk.signal.manager.api.Contact;
 import org.asamk.signal.manager.api.Device;
@@ -17,15 +18,19 @@ import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.api.Identity;
 import org.asamk.signal.manager.api.IdentityVerificationCode;
 import org.asamk.signal.manager.api.InactiveGroupLinkException;
+import org.asamk.signal.manager.api.IncorrectPinException;
 import org.asamk.signal.manager.api.InvalidDeviceLinkException;
 import org.asamk.signal.manager.api.InvalidStickerException;
 import org.asamk.signal.manager.api.InvalidUsernameException;
 import org.asamk.signal.manager.api.LastGroupAdminException;
 import org.asamk.signal.manager.api.Message;
 import org.asamk.signal.manager.api.MessageEnvelope;
+import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
 import org.asamk.signal.manager.api.NotAGroupMemberException;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
 import org.asamk.signal.manager.api.Pair;
+import org.asamk.signal.manager.api.PinLockedException;
+import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.manager.api.ReceiveConfig;
 import org.asamk.signal.manager.api.Recipient;
 import org.asamk.signal.manager.api.RecipientAddress;
@@ -166,6 +171,20 @@ public class DbusManagerImpl implements Manager {
         throw new UnsupportedOperationException();
     }
 
+    @Override
+    public void startChangeNumber(
+            final String newNumber, final boolean voiceVerification, final String captcha
+    ) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void finishChangeNumber(
+            final String newNumber, final String verificationCode, final String pin
+    ) throws IncorrectPinException, PinLockedException, IOException {
+        throw new UnsupportedOperationException();
+    }
+
     @Override
     public void unregister() throws IOException {
         signal.unregister();
index 311b78afceb533303415a04e1dceed7c166a5fe6..3fa9aecd33abe1c2a006c9956c6fdf453ba0a752 100644 (file)
@@ -2,9 +2,11 @@ package org.asamk.signal.util;
 
 import org.asamk.signal.commands.exceptions.UserErrorException;
 import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.GroupId;
 import org.asamk.signal.manager.api.GroupIdFormatException;
 import org.asamk.signal.manager.api.InvalidNumberException;
+import org.asamk.signal.manager.api.RateLimitException;
 import org.asamk.signal.manager.api.RecipientIdentifier;
 
 import java.util.Collection;
@@ -96,4 +98,29 @@ public class CommandUtil {
             throw new UserErrorException("Invalid phone number '" + recipientString + "': " + e.getMessage(), e);
         }
     }
+
+    public static String getCaptchaRequiredMessage(final CaptchaRequiredException e, final boolean captchaProvided) {
+        String message;
+        if (!captchaProvided) {
+            message = """
+                      Captcha required for verification, use --captcha CAPTCHA
+                      To get the token, go to https://signalcaptchas.org/registration/generate.html
+                      Check the developer tools (F12) console for a failed redirect to signalcaptcha://
+                      Everything after signalcaptcha:// is the captcha token.""";
+        } else {
+            message = "Invalid captcha given.";
+        }
+        if (e.getNextAttemptTimestamp() > 0) {
+            message += "\nNext Captcha may be provided at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
+        }
+        return message;
+    }
+
+    public static String getRateLimitMessage(final RateLimitException e) {
+        String message = "Rate limit reached";
+        if (e.getNextAttemptTimestamp() > 0) {
+            message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
+        }
+        return message;
+    }
 }