Implement change phone number

Closes #1240
This commit is contained in:
AsamK 2023-10-12 21:15:00 +02:00
parent 56ee173d03
commit 33c4e17c0d
15 changed files with 382 additions and 63 deletions

View file

@ -380,6 +380,9 @@
"name":"java.util.Locale", "name":"java.util.Locale",
"methods":[{"name":"getUnicodeLocaleType","parameterTypes":["java.lang.String"] }] "methods":[{"name":"getUnicodeLocaleType","parameterTypes":["java.lang.String"] }]
}, },
{
"name":"java.util.Map"
},
{ {
"name":"java.util.Optional", "name":"java.util.Optional",
"allDeclaredFields":true, "allDeclaredFields":true,
@ -505,9 +508,15 @@
{ {
"name":"kotlin.collections.List" "name":"kotlin.collections.List"
}, },
{
"name":"kotlin.collections.Map"
},
{ {
"name":"kotlin.collections.MutableList" "name":"kotlin.collections.MutableList"
}, },
{
"name":"kotlin.collections.MutableMap"
},
{ {
"name":"kotlin.jvm.JvmStatic", "name":"kotlin.jvm.JvmStatic",
"queryAllDeclaredMethods":true "queryAllDeclaredMethods":true
@ -545,7 +554,7 @@
"name":"org.asamk.Signal", "name":"org.asamk.Signal",
"allDeclaredMethods":true, "allDeclaredMethods":true,
"allDeclaredClasses":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.asamk.Signal$Configuration",
@ -2049,9 +2058,10 @@
{ {
"name":"org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest", "name":"org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest",
"allDeclaredFields":true, "allDeclaredFields":true,
"allDeclaredClasses":true,
"queryAllDeclaredMethods":true, "queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":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", "name":"org.whispersystems.signalservice.api.groupsv2.CredentialResponse",

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.Configuration;
import org.asamk.signal.manager.api.Device; import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl; 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.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode; import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.InactiveGroupLinkException; 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.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.LastGroupAdminException; import org.asamk.signal.manager.api.LastGroupAdminException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; 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.NotAGroupMemberException;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PendingAdminApprovalException; 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.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
@ -107,6 +112,14 @@ public interface Manager extends Closeable {
*/ */
void deleteUsername() throws IOException; 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 unregister() throws IOException;
void deleteAccount() throws IOException; void deleteAccount() throws IOException;

View file

@ -14,16 +14,20 @@ import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException; 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.KyberPreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord; 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.BaseUsernameException;
import org.signal.libsignal.usernames.Username; import org.signal.libsignal.usernames.Username;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; 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.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI; import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; 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.SignedPreKeyEntity;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException; import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; 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.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity; import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; 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 org.whispersystems.util.Base64UrlSafe;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; 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; import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class AccountHelper { public class AccountHelper {
@ -139,7 +148,7 @@ public class AccountHelper {
} }
public void startChangeNumber( public void startChangeNumber(
String newNumber, String captcha, boolean voiceVerification String newNumber, boolean voiceVerification, String captcha
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException { ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException {
final var accountManager = dependencies.createUnauthenticatedAccountManager(newNumber, account.getPassword()); final var accountManager = dependencies.createUnauthenticatedAccountManager(newNumber, account.getPassword());
String sessionId = NumberVerificationUtils.handleVerificationSession(accountManager, String sessionId = NumberVerificationUtils.handleVerificationSession(accountManager,
@ -153,12 +162,92 @@ public class AccountHelper {
public void finishChangeNumber( public void finishChangeNumber(
String newNumber, String verificationCode, String pin String newNumber, String verificationCode, String pin
) throws IncorrectPinException, PinLockedException, IOException { ) throws IncorrectPinException, PinLockedException, IOException {
// TODO create new PNI identity key for (var attempts = 0; attempts < 5; attempts++) {
final List<OutgoingPushMessage> deviceMessages = null; try {
final Map<String, SignedPreKeyEntity> devicePniSignedPreKeys = null; finishChangeNumberInternal(newNumber, verificationCode, pin);
final Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys = null; break;
final Map<String, Integer> pniRegistrationIds = null; } catch (MismatchedDevicesException e) {
var sessionId = account.getSessionId(account.getNumber()); 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, final var result = NumberVerificationUtils.verifyNumber(sessionId,
verificationCode, verificationCode,
pin, pin,
@ -166,7 +255,7 @@ public class AccountHelper {
(sessionId1, verificationCode1, registrationLock) -> { (sessionId1, verificationCode1, registrationLock) -> {
final var accountManager = dependencies.getAccountManager(); final var accountManager = dependencies.getAccountManager();
try { try {
Utils.handleResponseException(accountManager.verifyAccount(verificationCode, sessionId1)); Utils.handleResponseException(accountManager.verifyAccount(verificationCode1, sessionId1));
} catch (AlreadyVerifiedException e) { } catch (AlreadyVerifiedException e) {
// Already verified so can continue changing number // Already verified so can continue changing number
} }
@ -175,14 +264,42 @@ public class AccountHelper {
null, null,
newNumber, newNumber,
registrationLock, registrationLock,
account.getPniIdentityKeyPair().getPublicKey(), pniIdentity.getPublicKey(),
deviceMessages, encryptedDeviceMessages,
devicePniSignedPreKeys, Utils.mapKeys(devicePniSignedPreKeys, Object::toString),
devicePniLastResortKyberPrekeys, Utils.mapKeys(devicePniLastResortKyberPreKeys, Object::toString),
pniRegistrationIds))); 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; public static final int USERNAME_MIN_LENGTH = 3;

View file

@ -40,12 +40,9 @@ import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
import org.signal.libsignal.metadata.ProtocolNoSessionException; import org.signal.libsignal.metadata.ProtocolNoSessionException;
import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
import org.signal.libsignal.metadata.SelfSendException; import org.signal.libsignal.metadata.SelfSendException;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.groups.GroupSessionBuilder; import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage; 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.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger; 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.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI; 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.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.Envelope; import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
@ -618,24 +614,10 @@ public final class IncomingMessageHandler {
if (syncMessage.getPniChangeNumber().isPresent()) { if (syncMessage.getPniChangeNumber().isPresent()) {
final var pniChangeNumber = syncMessage.getPniChangeNumber().get(); final var pniChangeNumber = syncMessage.getPniChangeNumber().get();
logger.debug("Received PNI change number sync message, applying."); logger.debug("Received PNI change number sync message, applying.");
if (pniChangeNumber.identityKeyPair != null final var updatedPniString = envelope.getUpdatedPni();
&& pniChangeNumber.registrationId != null if (updatedPniString != null && !updatedPniString.isEmpty()) {
&& pniChangeNumber.signedPreKey != null final var updatedPni = ServiceId.PNI.parseOrThrow(updatedPniString);
&& !envelope.getUpdatedPni().isEmpty()) { context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni);
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);
}
} }
} }
return actions; return actions;

View file

@ -112,7 +112,12 @@ public class PreKeyHelper {
preKeyRecords, preKeyRecords,
lastResortKyberPreKeyRecord, lastResortKyberPreKeyRecord,
kyberPreKeyRecords); 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)); cleanSignedPreKeys((serviceIdType));

View file

@ -19,6 +19,7 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.Configuration;
import org.asamk.signal.manager.api.Device; import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl; 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.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode; import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.InactiveGroupLinkException; 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.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.LastGroupAdminException; import org.asamk.signal.manager.api.LastGroupAdminException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; 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.NotAGroupMemberException;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PendingAdminApprovalException; 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.Profile;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
@ -317,6 +322,26 @@ public class ManagerImpl implements Manager {
context.getAccountHelper().deleteUsername(); 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 @Override
public void unregister() throws IOException { public void unregister() throws IOException {
context.getAccountHelper().unregister(); context.getAccountHelper().unregister();

View file

@ -1383,7 +1383,6 @@ public class SignalAccount implements Closeable {
if (oldPni != null && !oldPni.equals(updatedPni)) { if (oldPni != null && !oldPni.equals(updatedPni)) {
// Clear data for old PNI // Clear data for old PNI
identityKeyStore.deleteIdentity(oldPni); identityKeyStore.deleteIdentity(oldPni);
clearAllPreKeys(ServiceIdType.PNI);
} }
this.pniAccountData.setServiceId(updatedPni); this.pniAccountData.setServiceId(updatedPni);
@ -1400,11 +1399,14 @@ public class SignalAccount implements Closeable {
setPniIdentityKeyPair(pniIdentityKeyPair); setPniIdentityKeyPair(pniIdentityKeyPair);
pniAccountData.setLocalRegistrationId(localPniRegistrationId); 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(); preKeyMetadata.nextSignedPreKeyId = pniSignedPreKey.getId();
accountData.getSignedPreKeyStore().removeSignedPreKey(pniSignedPreKey.getId());
addSignedPreKey(ServiceIdType.PNI, pniSignedPreKey); addSignedPreKey(ServiceIdType.PNI, pniSignedPreKey);
if (lastResortKyberPreKey != null) { if (lastResortKyberPreKey != null) {
preKeyMetadata.nextKyberPreKeyId = lastResortKyberPreKey.getId(); preKeyMetadata.nextKyberPreKeyId = lastResortKyberPreKey.getId();
accountData.getKyberPreKeyStore().removeKyberPreKey(lastResortKyberPreKey.getId());
addLastResortKyberPreKey(ServiceIdType.PNI, lastResortKyberPreKey); addLastResortKyberPreKey(ServiceIdType.PNI, lastResortKyberPreKey);
} }
save(); save();

View 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.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.helper.PinHelper; 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.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.kbs.MasterKey; import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException; import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException;
@ -24,6 +26,8 @@ import java.util.function.Consumer;
public class NumberVerificationUtils { public class NumberVerificationUtils {
private final static Logger logger = LoggerFactory.getLogger(NumberVerificationUtils.class);
public static String handleVerificationSession( public static String handleVerificationSession(
SignalServiceAccountManager accountManager, SignalServiceAccountManager accountManager,
String sessionId, String sessionId,
@ -143,7 +147,7 @@ public class NumberVerificationUtils {
private static RegistrationSessionMetadataResponse requestValidSession( private static RegistrationSessionMetadataResponse requestValidSession(
final SignalServiceAccountManager accountManager final SignalServiceAccountManager accountManager
) throws NoSuchSessionException, IOException { ) throws IOException {
return Utils.handleResponseException(accountManager.createRegistrationSession(null, "", "")); return Utils.handleResponseException(accountManager.createRegistrationSession(null, "", ""));
} }
@ -153,6 +157,7 @@ public class NumberVerificationUtils {
try { try {
return validateSession(accountManager, sessionId); return validateSession(accountManager, sessionId);
} catch (NoSuchSessionException e) { } catch (NoSuchSessionException e) {
logger.debug("No registration session, creating new one.");
return requestValidSession(accountManager); return requestValidSession(accountManager);
} }
} }

View file

@ -25,6 +25,8 @@ import java.util.Spliterator;
import java.util.Spliterators; import java.util.Spliterators;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
@ -122,6 +124,10 @@ public class Utils {
}, leftStream.isParallel() || rightStream.isParallel()); }, 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) { public static Map<String, String> getQueryMap(String query) {
var params = query.split("&"); var params = query.split("&");
var map = new HashMap<String, String>(); var map = new HashMap<String, String>();

View file

@ -14,6 +14,7 @@ public class Commands {
addCommand(new BlockCommand()); addCommand(new BlockCommand());
addCommand(new DaemonCommand()); addCommand(new DaemonCommand());
addCommand(new DeleteLocalAccountDataCommand()); addCommand(new DeleteLocalAccountDataCommand());
addCommand(new FinishChangeNumberCommand());
addCommand(new FinishLinkCommand()); addCommand(new FinishLinkCommand());
addCommand(new GetAttachmentCommand()); addCommand(new GetAttachmentCommand());
addCommand(new GetUserStatusCommand()); addCommand(new GetUserStatusCommand());
@ -43,6 +44,7 @@ public class Commands {
addCommand(new SendTypingCommand()); addCommand(new SendTypingCommand());
addCommand(new SetPinCommand()); addCommand(new SetPinCommand());
addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new SubmitRateLimitChallengeCommand());
addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand()); addCommand(new StartLinkCommand());
addCommand(new TrustCommand()); addCommand(new TrustCommand());
addCommand(new UnblockCommand()); addCommand(new UnblockCommand());

View file

@ -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);
}
}
}

View 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.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.output.JsonWriter; import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.util.DateUtils; import org.asamk.signal.util.CommandUtil;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -69,26 +69,10 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
try { try {
m.register(voiceVerification, captcha); m.register(voiceVerification, captcha);
} catch (RateLimitException e) { } catch (RateLimitException e) {
String message = "Rate limit reached"; final var message = CommandUtil.getRateLimitMessage(e);
if (e.getNextAttemptTimestamp() > 0) {
message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
}
throw new RateLimitErrorException(message, e); throw new RateLimitErrorException(message, e);
} catch (CaptchaRequiredException e) { } catch (CaptchaRequiredException e) {
String message; final var message = CommandUtil.getCaptchaRequiredMessage(e, captcha != null);
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());
}
throw new UserErrorException(message); throw new UserErrorException(message);
} catch (NonNormalizedPhoneNumberException e) { } catch (NonNormalizedPhoneNumberException e) {
throw new UserErrorException("Failed to register: " + e.getMessage(), e); throw new UserErrorException("Failed to register: " + e.getMessage(), e);

View file

@ -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);
}
}
}

View file

@ -4,6 +4,7 @@ import org.asamk.Signal;
import org.asamk.signal.DbusConfig; import org.asamk.signal.DbusConfig;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.Configuration;
import org.asamk.signal.manager.api.Contact; import org.asamk.signal.manager.api.Contact;
import org.asamk.signal.manager.api.Device; 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.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode; import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.InactiveGroupLinkException; 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.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.LastGroupAdminException; import org.asamk.signal.manager.api.LastGroupAdminException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; 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.NotAGroupMemberException;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
import org.asamk.signal.manager.api.Pair; 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.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientAddress; import org.asamk.signal.manager.api.RecipientAddress;
@ -166,6 +171,20 @@ public class DbusManagerImpl implements Manager {
throw new UnsupportedOperationException(); 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 @Override
public void unregister() throws IOException { public void unregister() throws IOException {
signal.unregister(); signal.unregister();

View file

@ -2,9 +2,11 @@ package org.asamk.signal.util;
import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager; 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.GroupId;
import org.asamk.signal.manager.api.GroupIdFormatException; import org.asamk.signal.manager.api.GroupIdFormatException;
import org.asamk.signal.manager.api.InvalidNumberException; import org.asamk.signal.manager.api.InvalidNumberException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
import java.util.Collection; import java.util.Collection;
@ -96,4 +98,29 @@ public class CommandUtil {
throw new UserErrorException("Invalid phone number '" + recipientString + "': " + e.getMessage(), e); 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;
}
} }