diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index 1af2fbf047..d24416d9ae 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -380,6 +380,9 @@ "name":"java.util.Locale", "methods":[{"name":"getUnicodeLocaleType","parameterTypes":["java.lang.String"] }] }, +{ + "name":"java.util.Map" +}, { "name":"java.util.Optional", "allDeclaredFields":true, @@ -505,9 +508,15 @@ { "name":"kotlin.collections.List" }, +{ + "name":"kotlin.collections.Map" +}, { "name":"kotlin.collections.MutableList" }, +{ + "name":"kotlin.collections.MutableMap" +}, { "name":"kotlin.jvm.JvmStatic", "queryAllDeclaredMethods":true @@ -545,7 +554,7 @@ "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", @@ -2049,9 +2058,10 @@ { "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", diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 264922a83f..7cba24f8fd 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -2,6 +2,7 @@ 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.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 @@ static boolean isSignalClientAvailable() { */ 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; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java index 5ff11b34fd..e021cc37c4 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java @@ -14,16 +14,20 @@ 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.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 void setPni( } 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 void startChangeNumber( public void finishChangeNumber( String newNumber, String verificationCode, String pin ) throws IncorrectPinException, PinLockedException, IOException { - // TODO create new PNI identity key - final List deviceMessages = null; - final Map devicePniSignedPreKeys = null; - final Map devicePniLastResortKyberPrekeys = null; - final Map 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(); + final var devicePniSignedPreKeys = new HashMap(); + final var devicePniLastResortKyberPreKeys = new HashMap(); + final var pniRegistrationIds = new HashMap(); + + final var selfDeviceId = account.getDeviceId(); + SyncMessage.PniChangeNumber selfChangeNumber = null; + + final var deviceIds = new ArrayList(); + 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 void finishChangeNumber( (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 void finishChangeNumber( 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; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 9d69632ecc..3f0abc4812 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -40,12 +40,9 @@ 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.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 @@ private List handleSyncMessage( 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; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java index 1961fc560f..c08da92c7e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java @@ -112,7 +112,12 @@ public void refreshPreKeysIfNecessary(ServiceIdType serviceIdType) throws IOExce 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)); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index bfb52ebfd1..a91a60996e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -19,6 +19,7 @@ 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.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 void deleteUsername() throws IOException { 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(); diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index a7954ca2b6..6b63920e4c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -1383,7 +1383,6 @@ public void setPni(final PNI updatedPni) { 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 void setNewPniIdentity( setPniIdentityKeyPair(pniIdentityKeyPair); pniAccountData.setLocalRegistrationId(localPniRegistrationId); - final var preKeyMetadata = getAccountData(ServiceIdType.PNI).getPreKeyMetadata(); + final AccountData 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(); diff --git a/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java index f8a14c0544..62474e6654 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java @@ -7,6 +7,8 @@ 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 @@ public class NumberVerificationUtils { + private final static Logger logger = LoggerFactory.getLogger(NumberVerificationUtils.class); + public static String handleVerificationSession( SignalServiceAccountManager accountManager, String sessionId, @@ -143,7 +147,7 @@ private static RegistrationSessionMetadataResponse validateSession( private static RegistrationSessionMetadataResponse requestValidSession( final SignalServiceAccountManager accountManager - ) throws NoSuchSessionException, IOException { + ) throws IOException { return Utils.handleResponseException(accountManager.createRegistrationSession(null, "", "")); } @@ -153,6 +157,7 @@ private static RegistrationSessionMetadataResponse getValidSession( try { return validateSession(accountManager, sessionId); } catch (NoSuchSessionException e) { + logger.debug("No registration session, creating new one."); return requestValidSession(accountManager); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java index 16e80e453f..6e9c701e13 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java @@ -25,6 +25,8 @@ 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 boolean tryAdvance(Consumer action) { }, leftStream.isParallel() || rightStream.isParallel()); } + public static Map mapKeys(Map map, Function keyMapper) { + return map.entrySet().stream().collect(Collectors.toMap(e -> keyMapper.apply(e.getKey()), Map.Entry::getValue)); + } + public static Map getQueryMap(String query) { var params = query.split("&"); var map = new HashMap(); diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 2783ca3748..719ba132ac 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -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 index 0000000000..c2c18dde4f --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/FinishChangeNumberCommand.java @@ -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); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index 1ca7bee2c1..876d1a8a99 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -16,7 +16,7 @@ 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 @@ private void register( 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 index 0000000000..7b18dbf095 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/StartChangeNumberCommand.java @@ -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); + } + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index d6cd52fa19..2e30417c58 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -4,6 +4,7 @@ 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.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 void deleteUsername() throws IOException { 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(); diff --git a/src/main/java/org/asamk/signal/util/CommandUtil.java b/src/main/java/org/asamk/signal/util/CommandUtil.java index 311b78afce..3fa9aecd33 100644 --- a/src/main/java/org/asamk/signal/util/CommandUtil.java +++ b/src/main/java/org/asamk/signal/util/CommandUtil.java @@ -2,9 +2,11 @@ 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 static RecipientIdentifier.Single getSingleRecipientIdentifier( 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; + } }