From dac64f773d8b409a27851a89cd555caa14a5addd Mon Sep 17 00:00:00 2001 From: AsamK Date: Sun, 20 Sep 2026 21:14:29 +0200 Subject: [PATCH] Add support for recovering account with recovery key --- .../signal/manager/RegistrationManager.java | 9 +- .../signal/manager/SignalAccountFiles.java | 44 ++++++ .../manager/api/TotpRequiredException.java | 10 ++ .../signal/manager/config/ServiceConfig.java | 7 +- .../internal/RegistrationManagerImpl.java | 133 +++++++++++++++++- .../signal/manager/storage/SignalAccount.java | 112 ++++++++++++++- .../storage/NumberlessAccountTest.java | 51 +++++++ man/signal-cli.1.adoc | 31 ++-- .../org/asamk/signal/AccountIdentifier.java | 32 +++++ src/main/java/org/asamk/signal/App.java | 6 +- .../signal/commands/RegisterCommand.java | 60 +++++++- .../dbus/DbusRegistrationManagerImpl.java | 10 ++ .../jsonrpc/SignalJsonRpcCommandHandler.java | 5 +- .../asamk/signal/AccountIdentifierTest.java | 23 +++ 14 files changed, 505 insertions(+), 28 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java create mode 100644 src/main/java/org/asamk/signal/AccountIdentifier.java create mode 100644 src/test/java/org/asamk/signal/AccountIdentifierTest.java diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java index 5f4f4463..7e89dcc3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -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(); diff --git a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java index 6834bff2..2e1caec2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java +++ b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java @@ -211,6 +211,11 @@ public class SignalAccountFiles { String number, Consumer 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 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)); + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java b/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java new file mode 100644 index 00000000..a85e43ba --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java @@ -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"); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java index ca496226..f30840c7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java @@ -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, diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java index 1fd86c26..075653bb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java @@ -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 diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 09a0caa9..560b5de7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -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) { diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java index f637702b..d12b94bc 100644 --- a/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java @@ -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()); + } + } } diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 375bfbb2..963d3c2c 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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. diff --git a/src/main/java/org/asamk/signal/AccountIdentifier.java b/src/main/java/org/asamk/signal/AccountIdentifier.java new file mode 100644 index 00000000..0d4fa936 --- /dev/null +++ b/src/main/java/org/asamk/signal/AccountIdentifier.java @@ -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; + } + } +} diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index 5287a6a8..1f7a0942 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -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; diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index 1cacc5f5..ff458f77 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -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 + ) {} } diff --git a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java index 0c02c680..ee1a04f8 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java @@ -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(); diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java index 5ce6c6c1..fcd0f228 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java @@ -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 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)); diff --git a/src/test/java/org/asamk/signal/AccountIdentifierTest.java b/src/test/java/org/asamk/signal/AccountIdentifierTest.java new file mode 100644 index 00000000..1f1ddf78 --- /dev/null +++ b/src/test/java/org/asamk/signal/AccountIdentifierTest.java @@ -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")); + } +}