mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-22 06:29:04 +00:00
Add support for recovering account with recovery key
This commit is contained in:
parent
926cd256e6
commit
dac64f773d
@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
|
||||
import java.io.Closeable;
|
||||
@ -17,13 +18,19 @@ public interface RegistrationManager extends Closeable {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException;
|
||||
|
||||
void verifyAccount(
|
||||
String verificationCode,
|
||||
String pin
|
||||
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
|
||||
|
||||
void registerWithRecoveryKey(
|
||||
String recoveryKey,
|
||||
boolean forceRegister,
|
||||
Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException;
|
||||
|
||||
void deleteLocalAccountData() throws IOException;
|
||||
|
||||
boolean isRegistered();
|
||||
|
||||
@ -211,6 +211,11 @@ public class SignalAccountFiles {
|
||||
String number,
|
||||
Consumer<Manager> newManagerListener
|
||||
) throws IOException {
|
||||
final var aci = ACI.parseOrNull(number);
|
||||
if (aci != null) {
|
||||
return initRegistrationManager(aci, newManagerListener);
|
||||
}
|
||||
|
||||
final var accountPath = accountsStore.getPathByNumber(number);
|
||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
|
||||
@ -250,4 +255,43 @@ public class SignalAccountFiles {
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
||||
}
|
||||
|
||||
private RegistrationManager initRegistrationManager(
|
||||
final ACI aci,
|
||||
final Consumer<Manager> newManagerListener
|
||||
) throws IOException {
|
||||
final var accountPath = accountsStore.getPathByAci(aci);
|
||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
|
||||
final var account = SignalAccount.create(pathConfig.dataPath(),
|
||||
newAccountPath,
|
||||
null,
|
||||
aci,
|
||||
serviceEnvironment,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
settings);
|
||||
account.initDatabase();
|
||||
return new RegistrationManagerImpl(account,
|
||||
pathConfig,
|
||||
serviceEnvironmentConfig,
|
||||
userAgent,
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
|
||||
}
|
||||
|
||||
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
|
||||
if (!aci.equals(account.getAci())) {
|
||||
account.close();
|
||||
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
|
||||
}
|
||||
account.initDatabase();
|
||||
return new RegistrationManagerImpl(account,
|
||||
pathConfig,
|
||||
serviceEnvironmentConfig,
|
||||
userAgent,
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TotpRequiredException extends IOException {
|
||||
|
||||
public TotpRequiredException() {
|
||||
super("A TOTP token is required or the supplied token is incorrect");
|
||||
}
|
||||
}
|
||||
@ -28,11 +28,14 @@ public class ServiceConfig {
|
||||
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
|
||||
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
|
||||
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
|
||||
public static AccountAttributes.Capabilities getCapabilities(
|
||||
final boolean isPrimaryDevice,
|
||||
final boolean hasPhoneNumber
|
||||
) {
|
||||
final var attachmentBackfill = !isPrimaryDevice;
|
||||
final var spqr = true;
|
||||
final var usernameSyncChangeMessage = !isPrimaryDevice;
|
||||
final var optionalPhoneNumber = !isPrimaryDevice;
|
||||
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber;
|
||||
return new AccountAttributes.Capabilities(true,
|
||||
true,
|
||||
attachmentBackfill,
|
||||
|
||||
@ -18,12 +18,14 @@ package org.asamk.signal.manager.internal;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.BadRequestException;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.UpdateProfile;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
@ -33,10 +35,14 @@ import org.asamk.signal.manager.helper.PinHelper;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.asamk.signal.manager.util.NumberVerificationUtils;
|
||||
import org.signal.core.models.AccountEntropyPool;
|
||||
import org.signal.core.models.MasterKey;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.network.api.RegistrationApiV2;
|
||||
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
|
||||
import org.signal.network.rest.SignalRestClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
@ -50,15 +56,25 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
|
||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
||||
|
||||
public class RegistrationManagerImpl implements RegistrationManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
|
||||
|
||||
private static final class RecoveryRequestFailedException extends IOException {
|
||||
|
||||
private RecoveryRequestFailedException(final String message, final Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private SignalAccount account;
|
||||
private final PathConfig pathConfig;
|
||||
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
||||
@ -107,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException {
|
||||
if (account.isRegistered()
|
||||
&& account.getServiceEnvironment() != null
|
||||
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
|
||||
@ -129,6 +145,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
|
||||
return;
|
||||
}
|
||||
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.getNumber() == null) {
|
||||
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
|
||||
}
|
||||
|
||||
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
|
||||
logger.trace("Creating verification session");
|
||||
@ -184,6 +207,108 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWithRecoveryKey(
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
if (account.getAci() == null) {
|
||||
throw new IOException("Recovery-key registration requires an ACI account identifier");
|
||||
}
|
||||
if (account.isRegistered() && !forceRegister) {
|
||||
throw new IOException("Account is already registered; use --reregister to register it again");
|
||||
}
|
||||
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
|
||||
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
|
||||
throw new IOException("Invalid recovery key");
|
||||
}
|
||||
recoverAccount(totp, false, accountEntropyPool);
|
||||
}
|
||||
|
||||
private boolean attemptRecoverAccount(
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
final var accountEntropyPool = account.getAccountEntropyPool();
|
||||
try {
|
||||
recoverAccount(totp, false, accountEntropyPool);
|
||||
logger.info("Reregistered existing account using its ACI and Account Entropy Pool.");
|
||||
return true;
|
||||
} catch (TotpRequiredException | RateLimitException e) {
|
||||
throw e;
|
||||
} catch (RecoveryRequestFailedException e) {
|
||||
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void recoverAccount(
|
||||
final Integer totp,
|
||||
final boolean includeRegistrationLock,
|
||||
final AccountEntropyPool accountEntropyPool
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
if (account.getPniIdentityKeyPair() == null) {
|
||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
|
||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
final var masterKey = accountEntropyPool.deriveMasterKey();
|
||||
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
|
||||
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
|
||||
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
|
||||
final var registrationApi = new RegistrationApiV2(restClient, true);
|
||||
|
||||
final RegistrationApiV2.RegisterAccountResponse response;
|
||||
try {
|
||||
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
|
||||
account.getPassword(),
|
||||
null,
|
||||
recoveryPassword,
|
||||
null,
|
||||
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
|
||||
toRegistrationPreKeys(aciPreKeys),
|
||||
toRegistrationPreKeys(pniPreKeys),
|
||||
null,
|
||||
true,
|
||||
account.getAci(),
|
||||
totp,
|
||||
cont));
|
||||
} catch (BadRequestException e) {
|
||||
switch (e.getError()) {
|
||||
case RegisterAccountError.RegistrationLock ignored -> {
|
||||
if (includeRegistrationLock) {
|
||||
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
|
||||
}
|
||||
recoverAccount(totp, true, accountEntropyPool);
|
||||
return;
|
||||
}
|
||||
case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException();
|
||||
case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored ->
|
||||
throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e);
|
||||
case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null);
|
||||
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
|
||||
throw new IOException("signal-cli is too old to register this account", e);
|
||||
default -> throw new IOException("Signal rejected recovery-key registration", e);
|
||||
}
|
||||
}
|
||||
|
||||
final var aci = ACI.parseOrThrow(response.getAci());
|
||||
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
|
||||
final var authCredentialSalt = response.getAuthCredentialSalt() == null
|
||||
? null
|
||||
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
|
||||
account.finishRecoveryRegistration(aci,
|
||||
pni,
|
||||
response.getE164(),
|
||||
accountEntropyPool,
|
||||
authCredentialSalt,
|
||||
aciPreKeys,
|
||||
pni == null ? null : pniPreKeys);
|
||||
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
|
||||
finishManagerRegistration(response.getStorageCapable());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteLocalAccountData() throws IOException {
|
||||
account.deleteAccountData();
|
||||
@ -291,13 +416,17 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
|
||||
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
|
||||
|
||||
finishManagerRegistration(response.isStorageCapable());
|
||||
}
|
||||
|
||||
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
|
||||
ManagerImpl m = null;
|
||||
try {
|
||||
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
|
||||
account = null;
|
||||
|
||||
m.refreshPreKeys();
|
||||
if (response.isStorageCapable()) {
|
||||
if (storageCapable) {
|
||||
m.syncRemoteStorage();
|
||||
}
|
||||
// Set an initial empty profile so user can be added to groups
|
||||
|
||||
@ -234,6 +234,28 @@ public class SignalAccount implements Closeable {
|
||||
IdentityKeyPair pniIdentityKey,
|
||||
ProfileKey profileKey,
|
||||
final Settings settings
|
||||
) throws IOException {
|
||||
return create(dataPath,
|
||||
accountPath,
|
||||
number,
|
||||
null,
|
||||
serviceEnvironment,
|
||||
aciIdentityKey,
|
||||
pniIdentityKey,
|
||||
profileKey,
|
||||
settings);
|
||||
}
|
||||
|
||||
public static SignalAccount create(
|
||||
File dataPath,
|
||||
String accountPath,
|
||||
String number,
|
||||
ACI aci,
|
||||
ServiceEnvironment serviceEnvironment,
|
||||
IdentityKeyPair aciIdentityKey,
|
||||
IdentityKeyPair pniIdentityKey,
|
||||
ProfileKey profileKey,
|
||||
final Settings settings
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
var fileName = getFileName(dataPath, accountPath);
|
||||
@ -252,6 +274,7 @@ public class SignalAccount implements Closeable {
|
||||
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
|
||||
signalAccount.dataPath = dataPath;
|
||||
signalAccount.aciAccountData.setServiceId(aci);
|
||||
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
|
||||
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
|
||||
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
|
||||
@ -395,6 +418,53 @@ public class SignalAccount implements Closeable {
|
||||
clearSessionId();
|
||||
}
|
||||
|
||||
public void finishRecoveryRegistration(
|
||||
final ACI aci,
|
||||
final PNI pni,
|
||||
final String number,
|
||||
final AccountEntropyPool accountEntropyPool,
|
||||
final byte[] authCredentialSalt,
|
||||
final PreKeyCollection aciPreKeys,
|
||||
final PreKeyCollection pniPreKeys
|
||||
) {
|
||||
this.pinMasterKey = null;
|
||||
this.accountEntropyPool = accountEntropyPool;
|
||||
this.authCredentialSalt = authCredentialSalt;
|
||||
this.number = number;
|
||||
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
|
||||
this.setStorageManifest(null);
|
||||
this.storageKey = null;
|
||||
this.encryptedDeviceName = null;
|
||||
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
this.isMultiDevice = false;
|
||||
this.registered = true;
|
||||
this.aciAccountData.setServiceId(aci);
|
||||
this.pniAccountData.setServiceId(pni);
|
||||
if (pni == null) {
|
||||
this.pniAccountData.setIdentityKeyPair(null);
|
||||
}
|
||||
init();
|
||||
this.registrationLockPin = null;
|
||||
setLastReceiveTimestamp(0L);
|
||||
setLastAppliedPniChangeServerTimestamp(0L);
|
||||
save();
|
||||
|
||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
||||
if (pni != null && pniPreKeys != null) {
|
||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
||||
}
|
||||
aciAccountData.getSessionStore().archiveAllSessions();
|
||||
pniAccountData.getSessionStore().archiveAllSessions();
|
||||
getSenderKeyStore().deleteAll();
|
||||
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
|
||||
trustSelfIdentity(ServiceIdType.ACI);
|
||||
if (pni != null) {
|
||||
trustSelfIdentity(ServiceIdType.PNI);
|
||||
}
|
||||
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
|
||||
clearSessionId();
|
||||
}
|
||||
|
||||
public void initDatabase() {
|
||||
getAccountDatabase();
|
||||
}
|
||||
@ -404,7 +474,7 @@ public class SignalAccount implements Closeable {
|
||||
}
|
||||
|
||||
private void migrateLegacyConfigs() {
|
||||
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
|
||||
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) {
|
||||
logger.trace("Migrating legacy parts of account file");
|
||||
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
@ -1426,10 +1496,36 @@ public class SignalAccount implements Closeable {
|
||||
}
|
||||
|
||||
public AccountAttributes.Capabilities getAccountCapabilities() {
|
||||
return getCapabilities(isPrimaryDevice());
|
||||
return getCapabilities(isPrimaryDevice(), number != null);
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
|
||||
return getAccountAttributesV2(false, getRegistrationLock());
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
||||
final boolean includePniRegistrationId,
|
||||
final String registrationLock
|
||||
) {
|
||||
return getAccountAttributesV2(includePniRegistrationId,
|
||||
registrationLock,
|
||||
getRecoveryPassword(),
|
||||
number == null ? null : isDiscoverableByPhoneNumber());
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
|
||||
final String registrationLock,
|
||||
final String recoveryPassword
|
||||
) {
|
||||
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
|
||||
}
|
||||
|
||||
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
||||
final boolean includePniRegistrationId,
|
||||
final String registrationLock,
|
||||
final String recoveryPassword,
|
||||
final Boolean discoverableByPhoneNumber
|
||||
) {
|
||||
final var attributes = getAccountAttributes(null);
|
||||
final var capabilities = attributes.getCapabilities();
|
||||
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
|
||||
@ -1437,10 +1533,10 @@ public class SignalAccount implements Closeable {
|
||||
attributes.getVoice(),
|
||||
attributes.getVideo(),
|
||||
attributes.getFetchesMessages(),
|
||||
attributes.getRegistrationLock(),
|
||||
registrationLock,
|
||||
attributes.getUnidentifiedAccessKey(),
|
||||
attributes.getUnrestrictedUnidentifiedAccess(),
|
||||
number == null ? null : attributes.getDiscoverableByPhoneNumber(),
|
||||
discoverableByPhoneNumber,
|
||||
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
|
||||
capabilities.getVersionedExpirationTimer(),
|
||||
capabilities.getAttachmentBackfill(),
|
||||
@ -1448,8 +1544,8 @@ public class SignalAccount implements Closeable {
|
||||
capabilities.getUsernameChangeSyncMessage(),
|
||||
capabilities.getOptionalPhoneNumber()),
|
||||
attributes.getName(),
|
||||
getPni() == null ? null : attributes.getPniRegistrationId(),
|
||||
attributes.getRecoveryPassword());
|
||||
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
|
||||
recoveryPassword);
|
||||
}
|
||||
|
||||
public ServiceId getAccountId(ServiceIdType serviceIdType) {
|
||||
@ -1662,6 +1758,10 @@ public class SignalAccount implements Closeable {
|
||||
return accountEntropyPool;
|
||||
}
|
||||
|
||||
public AccountEntropyPool getAccountEntropyPool() {
|
||||
return accountEntropyPool;
|
||||
}
|
||||
|
||||
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
|
||||
this.accountEntropyPool = accountEntropyPool;
|
||||
if (accountEntropyPool != null) {
|
||||
|
||||
@ -5,6 +5,7 @@ import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.signal.core.models.AccountEntropyPool;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
|
||||
@ -61,4 +62,54 @@ class NumberlessAccountTest {
|
||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var entropyPool = AccountEntropyPool.Companion.generate();
|
||||
final var salt = new byte[32];
|
||||
salt[0] = 42;
|
||||
try (final var account = SignalAccount.create(directory.toFile(),
|
||||
"account",
|
||||
null,
|
||||
aci,
|
||||
ServiceEnvironment.STAGING,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
Settings.DEFAULT)) {
|
||||
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
|
||||
}
|
||||
|
||||
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
|
||||
assertTrue(account.isRegistered());
|
||||
assertTrue(account.isPrimaryDevice());
|
||||
assertEquals(aci, account.getAci());
|
||||
assertNull(account.getNumber());
|
||||
assertNull(account.getPni());
|
||||
assertNull(account.getPniIdentityKeyPair());
|
||||
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
|
||||
assertArrayEquals(salt, account.getAuthCredentialSalt());
|
||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
try (final var account = SignalAccount.create(directory.toFile(),
|
||||
"account",
|
||||
"+12025550123",
|
||||
aci,
|
||||
ServiceEnvironment.STAGING,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
Settings.DEFAULT)) {
|
||||
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
|
||||
.getDiscoverableByPhoneNumber());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,8 @@ signal-cli - A commandline interface for the Signal messenger
|
||||
|
||||
signal-cli is a commandline interface for libsignal-service-java.
|
||||
It supports registering, verifying, sending and receiving messages.
|
||||
For registering you need a phone number where you can receive SMS or incoming calls.
|
||||
Accounts can be registered using a phone number that can receive SMS or incoming calls.
|
||||
Existing accounts can also be recovered using the Account Key and Recovery Key shown by Signal Android.
|
||||
signal-cli was primarily developed to be used on servers to notify admins of important events.
|
||||
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
|
||||
|
||||
@ -65,7 +66,8 @@ Make sure you have full read/write access to the given directory.
|
||||
*-a* ACCOUNT, *--account* ACCOUNT::
|
||||
Specify your phone number, that will be your identifier.
|
||||
The phone number must include the country calling code, i.e. the number must start with a "+" sign.
|
||||
An ACI (Account ID) can also select an existing account and is required for an account without a phone number.
|
||||
An ACI (the Account Key shown by Signal Android) can also select an account and is required for an account without a phone number.
|
||||
The Account Key can be given as 32 hexadecimal characters or as a UUID with dashes.
|
||||
|
||||
This flag must not be given for the `link` command.
|
||||
It is optional for the `daemon` command.
|
||||
@ -103,12 +105,13 @@ Disable message send log (for resending messages that recipient couldn't decrypt
|
||||
|
||||
=== register
|
||||
|
||||
Register a phone number with SMS or voice verification.
|
||||
Use the verify command to complete the verification.
|
||||
Register a phone number with SMS or voice verification, or recover an existing account using its Account Key and Recovery Key.
|
||||
Use the verify command to complete SMS or voice verification.
|
||||
|
||||
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
|
||||
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
|
||||
If the account was deleted (with --delete-account) it cannot be reactivated.
|
||||
If normal recovery of an existing local account fails and its Account Key and Recovery Key are available locally, recovery using those credentials is attempted before SMS verification.
|
||||
|
||||
*-v*, *--voice*::
|
||||
The verification should be done over voice, not SMS.
|
||||
@ -121,11 +124,23 @@ For the staging environment, use: https://signalcaptchas.org/staging/registratio
|
||||
After solving the captcha, right-click on the "Open Signal" link and copy the link.
|
||||
|
||||
*--reregister*::
|
||||
Register even if account is already registered.
|
||||
Register even if the local account is already registered.
|
||||
This option is required when using `--recovery-key` with an account that is still marked as registered locally.
|
||||
|
||||
*--recovery-key* RECOVERY-KEY::
|
||||
Recover an existing account using the 64-character Recovery Key shown by Signal Android.
|
||||
Specify the corresponding ACI (Account Key) with `-a`.
|
||||
This option cannot be combined with `--voice` or `--captcha`.
|
||||
|
||||
*--totp* TOKEN::
|
||||
The six-digit TOTP token requested during Account Key and Recovery Key based account recovery.
|
||||
If automatic account recovery reports that a TOTP token is required, rerun `register` with both `--recovery-key` and `--totp`.
|
||||
This option requires `--recovery-key`.
|
||||
This option cannot be combined with `--voice` or `--captcha`.
|
||||
|
||||
=== verify
|
||||
|
||||
Verify the number using the code received via SMS or voice.
|
||||
Verify a number using the code received via SMS or voice.
|
||||
|
||||
VERIFICATIONCODE::
|
||||
The verification code.
|
||||
@ -252,8 +267,8 @@ If you want to link to an Android/iOS device, scan the QR code that signal-cli p
|
||||
If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode).
|
||||
|
||||
Accounts without a phone number can also be linked.
|
||||
After linking, use the account's ACI (Account ID) with `-a`; recipient phone numbers must include the international country code.
|
||||
Creating or recovering a primary account without a phone number using an Account Key is not supported.
|
||||
After linking, use the account's ACI (Account Key) with `-a`; recipient phone numbers must include the international country code.
|
||||
Existing primary accounts without a phone number can be recovered using the `register --recovery-key` command.
|
||||
|
||||
*-n* NAME, *--name* NAME::
|
||||
Optionally specify a name to describe this new device.
|
||||
|
||||
32
src/main/java/org/asamk/signal/AccountIdentifier.java
Normal file
32
src/main/java/org/asamk/signal/AccountIdentifier.java
Normal file
@ -0,0 +1,32 @@
|
||||
package org.asamk.signal;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class AccountIdentifier {
|
||||
|
||||
private AccountIdentifier() {
|
||||
}
|
||||
|
||||
public static String normalize(final String identifier) {
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final var compact = identifier.replace("-", "").replaceAll("\\s", "").toLowerCase(Locale.ROOT);
|
||||
if (compact.length() != 32 || !compact.matches("[0-9a-f]{32}")) {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
final var uuid = "%s-%s-%s-%s-%s".formatted(compact.substring(0, 8),
|
||||
compact.substring(8, 12),
|
||||
compact.substring(12, 16),
|
||||
compact.substring(16, 20),
|
||||
compact.substring(20));
|
||||
try {
|
||||
return UUID.fromString(uuid).toString();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,7 +142,7 @@ public class App {
|
||||
final var outputWriter = getOutputWriter(command);
|
||||
final var commandHandler = new CommandHandler(ns, outputWriter);
|
||||
|
||||
var account = ns.getString("account");
|
||||
var account = AccountIdentifier.normalize(ns.getString("account"));
|
||||
|
||||
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
|
||||
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
|
||||
@ -187,8 +187,8 @@ public class App {
|
||||
}
|
||||
|
||||
if (command instanceof RegistrationCommand registrationCommand) {
|
||||
if (!Manager.isValidNumber(account, null)) {
|
||||
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
|
||||
throw new UserErrorException("Invalid account (E164 phone number or Account Key).");
|
||||
}
|
||||
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
|
||||
return;
|
||||
|
||||
@ -15,6 +15,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.output.JsonWriter;
|
||||
import org.asamk.signal.util.CommandUtil;
|
||||
@ -41,6 +42,8 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
subparser.addArgument("--reregister")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Register even if account is already registered");
|
||||
subparser.addArgument("--recovery-key").help("Recover an account using its 64-character Signal Recovery Key.");
|
||||
subparser.addArgument("--totp").help("A six-digit TOTP token required for account recovery.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -48,8 +51,10 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
|
||||
final var captcha = ns.getString("captcha");
|
||||
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
|
||||
final var recoveryKey = ns.getString("recovery-key");
|
||||
final var totp = ns.getString("totp");
|
||||
|
||||
register(m, voiceVerification, captcha, reregister);
|
||||
register(m, voiceVerification, captcha, reregister, recoveryKey, totp);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -68,15 +73,53 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
final RegistrationManager m,
|
||||
final JsonWriter jsonWriter
|
||||
) throws CommandException {
|
||||
register(m, Boolean.TRUE.equals(request.voice()), request.captcha(), Boolean.TRUE.equals(request.reregister()));
|
||||
register(m,
|
||||
Boolean.TRUE.equals(request.voice()),
|
||||
request.captcha(),
|
||||
Boolean.TRUE.equals(request.reregister()),
|
||||
request.recoveryKey(),
|
||||
request.totp());
|
||||
}
|
||||
|
||||
private void register(
|
||||
final RegistrationManager m,
|
||||
final boolean voiceVerification,
|
||||
final String captcha,
|
||||
final boolean reregister
|
||||
final boolean reregister,
|
||||
final String recoveryKey,
|
||||
final String totpValue
|
||||
) throws CommandException {
|
||||
if (recoveryKey == null && totpValue != null) {
|
||||
throw new UserErrorException("--totp requires --recovery-key");
|
||||
}
|
||||
|
||||
final Integer totp;
|
||||
if (totpValue == null) {
|
||||
totp = null;
|
||||
} else if (!totpValue.matches("[0-9]{6}")) {
|
||||
throw new UserErrorException("TOTP token must contain exactly six digits");
|
||||
} else {
|
||||
totp = Integer.parseInt(totpValue);
|
||||
}
|
||||
|
||||
if (recoveryKey != null) {
|
||||
if (voiceVerification || captcha != null) {
|
||||
throw new UserErrorException("--recovery-key cannot be combined with --voice or --captcha");
|
||||
}
|
||||
try {
|
||||
m.registerWithRecoveryKey(recoveryKey, reregister, totp);
|
||||
} catch (RateLimitException e) {
|
||||
final var message = CommandUtil.getRateLimitMessage(e);
|
||||
throw new RateLimitErrorException(message, e);
|
||||
} catch (TotpRequiredException e) {
|
||||
throw new UserErrorException("A TOTP token is required; rerun register with --totp TOKEN");
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
||||
e.getClass().getSimpleName()), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
m.register(voiceVerification, captcha, reregister);
|
||||
} catch (RateLimitException e) {
|
||||
@ -87,6 +130,9 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
throw new UserErrorException(message);
|
||||
} catch (NonNormalizedPhoneNumberException e) {
|
||||
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
|
||||
} catch (TotpRequiredException e) {
|
||||
throw new UserErrorException(
|
||||
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
||||
e.getClass().getSimpleName()), e);
|
||||
@ -99,5 +145,11 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
}
|
||||
}
|
||||
|
||||
public record RegistrationParams(Boolean voice, String captcha, Boolean reregister) {}
|
||||
public record RegistrationParams(
|
||||
Boolean voice,
|
||||
String captcha,
|
||||
Boolean reregister,
|
||||
String recoveryKey,
|
||||
String totp
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -53,6 +54,15 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWithRecoveryKey(
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, TotpRequiredException {
|
||||
throw new IOException("Recovery-key registration is not supported over D-Bus");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteLocalAccountData() throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
|
||||
@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.asamk.signal.AccountIdentifier;
|
||||
import org.asamk.signal.commands.Command;
|
||||
import org.asamk.signal.commands.JsonRpcMultiCommand;
|
||||
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
|
||||
@ -126,7 +127,7 @@ public class SignalJsonRpcCommandHandler {
|
||||
|
||||
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
|
||||
if (params != null && params.hasNonNull("account")) {
|
||||
final var manager = c.getManager(params.get("account").asText());
|
||||
final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText()));
|
||||
((ObjectNode) params).remove("account");
|
||||
if (manager == null) {
|
||||
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
|
||||
@ -140,7 +141,7 @@ public class SignalJsonRpcCommandHandler {
|
||||
|
||||
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
|
||||
if (params != null && params.has("account")) {
|
||||
final var account = params.get("account").asText();
|
||||
final var account = AccountIdentifier.normalize(params.get("account").asText());
|
||||
((ObjectNode) params).remove("account");
|
||||
try {
|
||||
return new Pair<>(account, c.getNewRegistrationManager(account));
|
||||
|
||||
23
src/test/java/org/asamk/signal/AccountIdentifierTest.java
Normal file
23
src/test/java/org/asamk/signal/AccountIdentifierTest.java
Normal file
@ -0,0 +1,23 @@
|
||||
package org.asamk.signal;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AccountIdentifierTest {
|
||||
|
||||
@Test
|
||||
void normalizesSignalAndroidAccountKeys() {
|
||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
||||
AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
|
||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
||||
AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesNonAccountIdentifiersUnchanged() {
|
||||
assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
|
||||
assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
|
||||
assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user