"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",
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;
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;
*/
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;
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;
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 {
}
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,
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,
(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
}
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;
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;
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;
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;
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));
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;
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;
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();
if (oldPni != null && !oldPni.equals(updatedPni)) {
// Clear data for old PNI
identityKeyStore.deleteIdentity(oldPni);
- clearAllPreKeys(ServiceIdType.PNI);
}
this.pniAccountData.setServiceId(updatedPni);
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();
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;
public class NumberVerificationUtils {
+ private final static Logger logger = LoggerFactory.getLogger(NumberVerificationUtils.class);
+
public static String handleVerificationSession(
SignalServiceAccountManager accountManager,
String sessionId,
private static RegistrationSessionMetadataResponse requestValidSession(
final SignalServiceAccountManager accountManager
- ) throws NoSuchSessionException, IOException {
+ ) throws IOException {
return Utils.handleResponseException(accountManager.createRegistrationSession(null, "", ""));
}
try {
return validateSession(accountManager, sessionId);
} catch (NoSuchSessionException e) {
+ logger.debug("No registration session, creating new one.");
return requestValidSession(accountManager);
}
}
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;
}, 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>();
addCommand(new BlockCommand());
addCommand(new DaemonCommand());
addCommand(new DeleteLocalAccountDataCommand());
+ addCommand(new FinishChangeNumberCommand());
addCommand(new FinishLinkCommand());
addCommand(new GetAttachmentCommand());
addCommand(new GetUserStatusCommand());
addCommand(new SendTypingCommand());
addCommand(new SetPinCommand());
addCommand(new SubmitRateLimitChallengeCommand());
+ addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand());
addCommand(new TrustCommand());
addCommand(new UnblockCommand());
--- /dev/null
+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);
+ }
+ }
+}
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;
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);
--- /dev/null
+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);
+ }
+ }
+}
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;
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;
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();
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;
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;
+ }
}