Implement support for usernames

This commit is contained in:
AsamK 2023-04-03 19:00:05 +02:00
parent 03f193b34c
commit 9f60ed534a
18 changed files with 440 additions and 46 deletions

View file

@ -195,6 +195,22 @@
{
"name":"org.signal.libsignal.protocol.state.SignedPreKeyStore"
},
{
"name":"org.signal.libsignal.usernames.BadNicknameCharacterException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooShortException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.zkgroup.InvalidInputException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]

View file

@ -672,6 +672,13 @@
"queryAllDeclaredConstructors":true,
"methods":[{"name":"deviceLinkUri","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.commands.UpdateAccountCommand$JsonAccountResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"username","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.commands.VerifyCommand$VerifyParams",
"allDeclaredFields":true,
@ -2535,6 +2542,12 @@
"allDeclaredMethods":true,
"allDeclaredConstructors":true
},
{
"name":"org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true
},
{
"name":"org.whispersystems.signalservice.internal.push.DeviceCode",
"allDeclaredFields":true,
@ -2553,6 +2566,13 @@
"allDeclaredMethods":true,
"allDeclaredConstructors":true
},
{
"name":"org.whispersystems.signalservice.internal.push.GetAciByUsernameResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"org.whispersystems.signalservice.internal.push.GroupMismatchedDevices",
"allDeclaredFields":true,
@ -2699,6 +2719,19 @@
{"name":"getSkipDeviceTransfer","parameterTypes":[] }
]
},
{
"name":"org.whispersystems.signalservice.internal.push.ReserveUsernameRequest",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true
},
{
"name":"org.whispersystems.signalservice.internal.push.ReserveUsernameResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"org.whispersystems.signalservice.internal.push.SendGroupMessageResponse",
"allDeclaredFields":true,

View file

@ -9,6 +9,7 @@ import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -77,6 +78,18 @@ public interface Manager extends Closeable {
*/
void updateProfile(UpdateProfile updateProfile) throws IOException;
/**
* Set a username for the account.
* If the username is null, it will be deleted.
*/
String setUsername(String username) throws IOException, InvalidUsernameException;
/**
* Set a username for the account.
* If the username is null, it will be deleted.
*/
void deleteUsername() throws IOException;
void unregister() throws IOException;
void deleteAccount() throws IOException;

View file

@ -25,6 +25,7 @@ import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -65,6 +66,7 @@ import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.StickerUtils;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalSessionLock;
@ -290,6 +292,20 @@ class ManagerImpl implements Manager {
context.getSyncHelper().sendSyncFetchProfileMessage();
}
@Override
public String setUsername(final String username) throws IOException, InvalidUsernameException {
try {
return context.getAccountHelper().reserveUsername(username);
} catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
}
}
@Override
public void deleteUsername() throws IOException {
context.getAccountHelper().deleteUsername();
}
@Override
public void unregister() throws IOException {
context.getAccountHelper().unregister();
@ -737,13 +753,18 @@ class ManagerImpl implements Manager {
@Override
public void deleteRecipient(final RecipientIdentifier.Single recipient) {
account.removeRecipient(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier()));
final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
account.removeRecipient(recipientIdOptional.get());
}
}
@Override
public void deleteContact(final RecipientIdentifier.Single recipient) {
account.getContactStore()
.deleteContact(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier()));
final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
account.getContactStore().deleteContact(recipientIdOptional.get());
}
}
@Override

View file

@ -0,0 +1,12 @@
package org.asamk.signal.manager.api;
public class InvalidUsernameException extends Exception {
public InvalidUsernameException(final String message) {
super(message);
}
public InvalidUsernameException(final String message, final Throwable cause) {
super(message, cause);
}
}

View file

@ -6,7 +6,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional;
import java.util.UUID;
public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
public record RecipientAddress(Optional<UUID> uuid, Optional<String> number, Optional<String> username) {
public static final UUID UNKNOWN_UUID = ServiceId.UNKNOWN.uuid();
@ -18,21 +18,25 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
*/
public RecipientAddress {
uuid = uuid.isPresent() && uuid.get().equals(UNKNOWN_UUID) ? Optional.empty() : uuid;
if (uuid.isEmpty() && number.isEmpty()) {
if (uuid.isEmpty() && number.isEmpty() && username.isEmpty()) {
throw new AssertionError("Must have either a UUID or E164 number!");
}
}
public RecipientAddress(UUID uuid, String e164) {
this(Optional.ofNullable(uuid), Optional.ofNullable(e164));
this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(UUID uuid, String e164, String username) {
this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.ofNullable(username));
}
public RecipientAddress(SignalServiceAddress address) {
this(Optional.of(address.getServiceId().uuid()), address.getNumber());
this(Optional.of(address.getServiceId().uuid()), address.getNumber(), Optional.empty());
}
public RecipientAddress(UUID uuid) {
this(Optional.of(uuid), Optional.empty());
this(Optional.of(uuid), Optional.empty(), Optional.empty());
}
public ServiceId getServiceId() {
@ -44,6 +48,8 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
return uuid.get().toString();
} else if (number.isPresent()) {
return number.get();
} else if (username.isPresent()) {
return username.get();
} else {
throw new AssertionError("Given the checks in the constructor, this should not be possible.");
}
@ -54,14 +60,16 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
return number.get();
} else if (uuid.isPresent()) {
return uuid.get().toString();
} else if (username.isPresent()) {
return username.get();
} else {
throw new AssertionError("Given the checks in the constructor, this should not be possible.");
}
}
public boolean matches(RecipientAddress other) {
return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || (
number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get())
);
return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get()))
|| (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()))
|| (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get()));
}
}

View file

@ -30,6 +30,10 @@ public sealed interface RecipientIdentifier {
return new Uuid(UUID.fromString(identifier));
}
if (identifier.startsWith("u:")) {
return new Username(identifier.substring(2));
}
final var normalizedNumber = PhoneNumberFormatter.formatNumber(identifier, localNumber);
if (!normalizedNumber.equals(identifier)) {
final Logger logger = LoggerFactory.getLogger(RecipientIdentifier.class);
@ -46,6 +50,8 @@ public sealed interface RecipientIdentifier {
return new Number(address.number().get());
} else if (address.uuid().isPresent()) {
return new Uuid(address.uuid().get());
} else if (address.username().isPresent()) {
return new Username(address.username().get());
}
throw new AssertionError("RecipientAddress without identifier");
}
@ -79,6 +85,19 @@ public sealed interface RecipientIdentifier {
}
}
record Username(String username) implements Single {
@Override
public String getIdentifier() {
return "u:" + username;
}
@Override
public RecipientAddress toPartialRecipientAddress() {
return new RecipientAddress(null, null, username);
}
}
record Group(GroupId groupId) implements RecipientIdentifier {
@Override

View file

@ -15,6 +15,8 @@ import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
@ -27,13 +29,18 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class AccountHelper {
private final static Logger logger = LoggerFactory.getLogger(AccountHelper.class);
@ -173,6 +180,83 @@ public class AccountHelper {
updateSelfIdentifiers(newNumber, account.getAci(), PNI.parseOrThrow(result.first().getPni()));
}
public static final int USERNAME_MIN_LENGTH = 3;
public static final int USERNAME_MAX_LENGTH = 32;
public String reserveUsername(String nickname) throws IOException, BaseUsernameException {
final var currentUsername = account.getUsername();
if (currentUsername != null) {
final var currentNickname = currentUsername.substring(0, currentUsername.indexOf('.'));
if (currentNickname.equals(nickname)) {
refreshCurrentUsername();
return currentUsername;
}
}
final var candidates = Username.generateCandidates(nickname, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH);
final var candidateHashes = new ArrayList<String>();
for (final var candidate : candidates) {
candidateHashes.add(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(candidate)));
}
final var response = dependencies.getAccountManager().reserveUsername(candidateHashes);
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash());
if (hashIndex == -1) {
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
throw new IOException("Unexpected username response");
}
logger.debug("[reserveUsername] Successfully reserved username.");
final var username = candidates.get(hashIndex);
dependencies.getAccountManager().confirmUsername(username, response);
account.setUsername(username);
account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
logger.debug("[confirmUsername] Successfully confirmed username.");
return username;
}
public void refreshCurrentUsername() throws IOException, BaseUsernameException {
final var localUsername = account.getUsername();
if (localUsername == null) {
return;
}
final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI();
final var serverUsernameHash = whoAmIResponse.getUsernameHash();
final var hasServerUsername = !isEmpty(serverUsernameHash);
final var localUsernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(localUsername));
if (!hasServerUsername) {
logger.debug("No remote username is set.");
}
if (!Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Local username hash does not match server username hash.");
}
if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Attempting to resynchronize username.");
tryReserveConfirmUsername(localUsername, localUsernameHash);
} else {
logger.debug("Username already set, not refreshing.");
}
}
private void tryReserveConfirmUsername(final String username, String localUsernameHash) throws IOException {
final var response = dependencies.getAccountManager().reserveUsername(List.of(localUsernameHash));
logger.debug("[reserveUsername] Successfully reserved existing username.");
dependencies.getAccountManager().confirmUsername(username, response);
logger.debug("[confirmUsername] Successfully confirmed existing username.");
}
public void deleteUsername() throws IOException {
dependencies.getAccountManager().deleteUsername();
account.setUsername(null);
logger.debug("[deleteUsername] Successfully deleted the username.");
}
public void setDeviceName(String deviceName) {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);

View file

@ -6,6 +6,8 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.ACI;
@ -13,6 +15,7 @@ import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.services.CdsiV2Service;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.util.Collection;
@ -47,7 +50,7 @@ public class RecipientHelper {
final var number = address.number().get();
final ServiceId serviceId;
try {
serviceId = getRegisteredUser(number);
serviceId = getRegisteredUserByNumber(number);
} catch (UnregisteredRecipientException e) {
logger.warn("Failed to get uuid for e164 number: {}", number);
// Return SignalServiceAddress with unknown UUID
@ -78,15 +81,33 @@ public class RecipientHelper {
public RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredRecipientException {
if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) {
return account.getRecipientResolver().resolveRecipient(ServiceId.from(uuidRecipient.uuid()));
} else {
final var number = ((RecipientIdentifier.Number) recipient).number();
return account.getRecipientStore().resolveRecipient(number, () -> {
} else if (recipient instanceof RecipientIdentifier.Number numberRecipient) {
final var number = numberRecipient.number();
return account.getRecipientStore().resolveRecipientByNumber(number, () -> {
try {
return getRegisteredUser(number);
return getRegisteredUserByNumber(number);
} catch (Exception e) {
return null;
}
});
} else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) {
final var username = usernameRecipient.username();
return account.getRecipientStore().resolveRecipientByUsername(username, () -> {
try {
return getRegisteredUserByUsername(username);
} catch (Exception e) {
return null;
}
});
}
throw new AssertionError("Unexpected RecipientIdentifier: " + recipient);
}
public Optional<RecipientId> resolveRecipientOptional(final RecipientIdentifier.Single recipient) {
try {
return Optional.of(resolveRecipient(recipient));
} catch (UnregisteredRecipientException e) {
return Optional.empty();
}
}
@ -96,7 +117,7 @@ public class RecipientHelper {
return recipientId;
}
final var number = address.getNumber().get();
final var serviceId = getRegisteredUser(number);
final var serviceId = getRegisteredUserByNumber(number);
return account.getRecipientTrustedResolver()
.resolveRecipientTrusted(new SignalServiceAddress(serviceId, number));
}
@ -111,7 +132,7 @@ public class RecipientHelper {
return registeredUsers;
}
private ServiceId getRegisteredUser(final String number) throws IOException, UnregisteredRecipientException {
private ServiceId getRegisteredUserByNumber(final String number) throws IOException, UnregisteredRecipientException {
final Map<String, RegisteredUser> aciMap;
try {
aciMap = getRegisteredUsers(Set.of(number));
@ -153,8 +174,9 @@ public class RecipientHelper {
return registeredUsers;
}
private ACI getRegisteredUserByUsername(String username) throws IOException {
return dependencies.getAccountManager().getAciByUsernameHash(username);
private ACI getRegisteredUserByUsername(String username) throws IOException, BaseUsernameException {
return dependencies.getAccountManager()
.getAciByUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
}
public record RegisteredUser(Optional<ACI> aci, Optional<PNI> pni) {

View file

@ -102,7 +102,11 @@ public class StorageHelper {
final var contactRecord = record.getContact().get();
final var address = new RecipientAddress(contactRecord.getServiceId(), contactRecord.getNumber().orElse(null));
final var recipientId = account.getRecipientResolver().resolveRecipient(address);
var recipientId = account.getRecipientResolver().resolveRecipient(address);
if (contactRecord.getUsername().isPresent()) {
recipientId = account.getRecipientTrustedResolver()
.resolveRecipientTrusted(contactRecord.getServiceId(), contactRecord.getUsername().get());
}
final var contact = account.getContactStore().getContact(recipientId);
final var blocked = contact != null && contact.isBlocked();
@ -257,6 +261,7 @@ public class StorageHelper {
});
}
account.getConfigurationStore().setPhoneNumberUnlisted(accountRecord.isPhoneNumberUnlisted());
account.setUsername(accountRecord.getUsername());
if (accountRecord.getProfileKey().isPresent()) {
ProfileKey profileKey;

View file

@ -22,7 +22,7 @@ import java.sql.SQLException;
public class AccountDatabase extends Database {
private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 11;
private static final long DATABASE_VERSION = 12;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@ -296,5 +296,13 @@ public class AccountDatabase extends Database {
""");
}
}
if (oldVersion < 12) {
logger.debug("Updating database: Adding username field");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD COLUMN username TEXT;
""");
}
}
}
}

View file

@ -120,6 +120,7 @@ public class SignalAccount implements Closeable {
private String accountPath;
private ServiceEnvironment serviceEnvironment;
private String number;
private String username;
private ACI aci;
private PNI pni;
private String sessionId;
@ -542,6 +543,9 @@ public class SignalAccount implements Closeable {
serviceEnvironment = ServiceEnvironment.valueOf(rootNode.get("serviceEnvironment").asText());
}
registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
if (rootNode.hasNonNull("usernameIdentifier")) {
username = rootNode.get("usernameIdentifier").asText();
}
if (rootNode.hasNonNull("uuid")) {
try {
aci = ACI.parseOrThrow(rootNode.get("uuid").asText());
@ -935,6 +939,7 @@ public class SignalAccount implements Closeable {
rootNode.put("version", CURRENT_STORAGE_VERSION)
.put("username", number)
.put("serviceEnvironment", serviceEnvironment == null ? null : serviceEnvironment.name())
.put("usernameIdentifier", username)
.put("uuid", aci == null ? null : aci.toString())
.put("pni", pni == null ? null : pni.toString())
.put("sessionId", sessionId)
@ -1297,6 +1302,15 @@ public class SignalAccount implements Closeable {
save();
}
public String getUsername() {
return username;
}
public void setUsername(final String username) {
this.username = username;
save();
}
public ServiceEnvironment getServiceEnvironment() {
return serviceEnvironment;
}
@ -1368,7 +1382,7 @@ public class SignalAccount implements Closeable {
}
public RecipientAddress getSelfRecipientAddress() {
return new RecipientAddress(aci, pni, number);
return new RecipientAddress(aci, pni, number, username);
}
public RecipientId getSelfRecipientId() {

View file

@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional;
public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni, Optional<String> number) {
public record RecipientAddress(
Optional<ServiceId> serviceId, Optional<PNI> pni, Optional<String> number, Optional<String> username
) {
/**
* Construct a RecipientAddress.
@ -38,23 +40,30 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
}
public RecipientAddress(Optional<ServiceId> serviceId, Optional<String> number) {
this(serviceId, Optional.empty(), number);
this(serviceId, Optional.empty(), number, Optional.empty());
}
public RecipientAddress(ServiceId serviceId, String e164) {
this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164));
this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(ServiceId serviceId, PNI pni, String e164) {
this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164));
this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(ServiceId serviceId, PNI pni, String e164, String username) {
this(Optional.ofNullable(serviceId),
Optional.ofNullable(pni),
Optional.ofNullable(e164),
Optional.ofNullable(username));
}
public RecipientAddress(SignalServiceAddress address) {
this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber());
this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber(), Optional.empty());
}
public RecipientAddress(org.asamk.signal.manager.api.RecipientAddress address) {
this(address.uuid().map(ServiceId::from), Optional.empty(), address.number());
this(address.uuid().map(ServiceId::from), Optional.empty(), address.number(), address.username());
}
public RecipientAddress(ServiceId serviceId) {
@ -66,7 +75,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni)
) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId,
address.pni.or(this::pni),
address.number.or(this::number));
address.number.or(this::number),
address.username.or(this::username));
}
public RecipientAddress removeIdentifiersFrom(RecipientAddress address) {
@ -74,7 +84,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
? Optional.empty()
: this.serviceId,
address.pni.equals(this.pni) || address.serviceId.equals(this.pni) ? Optional.empty() : this.pni,
address.number.equals(this.number) ? Optional.empty() : this.number);
address.number.equals(this.number) ? Optional.empty() : this.number,
address.username.equals(this.username) ? Optional.empty() : this.username);
}
public ServiceId getServiceId() {
@ -118,13 +129,17 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
}
public boolean hasSingleIdentifier() {
return serviceId().isEmpty() || number.isEmpty();
final var identifiersCount = serviceId().map(s -> 1).orElse(0)
+ number().map(s -> 1).orElse(0)
+ username().map(s -> 1).orElse(0);
return identifiersCount == 1;
}
public boolean hasIdentifiersOf(RecipientAddress address) {
return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.equals(pni))
&& (address.pni.isEmpty() || address.pni.equals(pni))
&& (address.number.isEmpty() || address.number.equals(number));
&& (address.number.isEmpty() || address.number.equals(number))
&& (address.username.isEmpty() || address.username.equals(username));
}
public boolean hasAdditionalIdentifiersThan(RecipientAddress address) {
@ -142,6 +157,10 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
number.isPresent() && (
address.number.isEmpty() || !address.number.equals(number)
)
) || (
username.isPresent() && (
address.username.isEmpty() || !address.username.equals(username)
)
);
}
@ -158,6 +177,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
}
public org.asamk.signal.manager.api.RecipientAddress toApiRecipientAddress() {
return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid), number());
return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid),
number(),
username());
}
}

View file

@ -52,6 +52,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
CREATE TABLE recipient (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT UNIQUE,
username TEXT UNIQUE,
uuid BLOB UNIQUE,
pni BLOB UNIQUE,
profile_key BLOB,
@ -93,7 +94,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
final var sql = (
"""
SELECT r.number, r.uuid, r.pni
SELECT r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r._id = ?
"""
@ -193,7 +194,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
return new RecipientId(recipientId, this);
}
public RecipientId resolveRecipient(
public RecipientId resolveRecipientByNumber(
final String number, Supplier<ServiceId> serviceIdSupplier
) throws UnregisteredRecipientException {
final Optional<RecipientWithAddress> byNumber;
@ -214,6 +215,28 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
return byNumber.get().id();
}
public RecipientId resolveRecipientByUsername(
final String username, Supplier<ServiceId> serviceIdSupplier
) throws UnregisteredRecipientException {
final Optional<RecipientWithAddress> byUsername;
try (final var connection = database.getConnection()) {
byUsername = findByUsername(connection, username);
} catch (SQLException e) {
throw new RuntimeException("Failed read from recipient store", e);
}
if (byUsername.isEmpty() || byUsername.get().address().serviceId().isEmpty()) {
final var serviceId = serviceIdSupplier.get();
if (serviceId == null) {
throw new UnregisteredRecipientException(new org.asamk.signal.manager.api.RecipientAddress(null,
null,
username));
}
return resolveRecipient(serviceId);
}
return byUsername.get().id();
}
public RecipientId resolveRecipient(RecipientAddress address) {
synchronized (recipientsLock) {
final RecipientId recipientId;
@ -247,7 +270,21 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Optional<ACI> aci, final Optional<PNI> pni, final Optional<String> number
) {
final var serviceId = aci.map(a -> (ServiceId) a).or(() -> pni);
return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number), false);
return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number, Optional.empty()), false);
}
@Override
public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) {
return resolveRecipientTrusted(new RecipientAddress(serviceId, null, null, username), false);
}
public RecipientId resolveRecipientTrusted(
final ACI aci, final String username
) {
return resolveRecipientTrusted(new RecipientAddress(Optional.of(aci),
Optional.empty(),
Optional.empty(),
Optional.of(username)), false);
}
@Override
@ -309,7 +346,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
SELECT r._id,
r.number, r.uuid, r.pni,
r.number, r.uuid, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities
@ -739,7 +776,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET number = ?, uuid = ?, pni = ?
SET number = ?, uuid = ?, pni = ?, username = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -747,7 +784,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setString(1, address.number().orElse(null));
statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setLong(4, recipientId.id());
statement.setString(4, address.username().orElse(null));
statement.setLong(5, recipientId.id());
statement.executeUpdate();
}
}
@ -800,7 +838,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final String number
) throws SQLException {
final var sql = """
SELECT r._id, r.number, r.uuid, r.pni
SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r.number = ?
LIMIT 1
@ -811,11 +849,26 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
private Optional<RecipientWithAddress> findByUsername(
final Connection connection, final String username
) throws SQLException {
final var sql = """
SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r.username = ?
LIMIT 1
""".formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setString(1, username);
return Utils.executeQueryForOptional(statement, this::getRecipientWithAddressFromResultSet);
}
}
private Optional<RecipientWithAddress> findByServiceId(
final Connection connection, final ServiceId serviceId
) throws SQLException {
final var sql = """
SELECT r._id, r.number, r.uuid, r.pni
SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r.uuid = ? OR r.pni = ?
LIMIT 1
@ -830,16 +883,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final RecipientAddress address
) throws SQLException {
final var sql = """
SELECT r._id, r.number, r.uuid, r.pni
SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r.uuid = ?1 OR r.pni = ?1 OR
r.uuid = ?2 OR r.pni = ?2 OR
r.number = ?3
r.number = ?3 OR
r.username = ?4
""".formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setBytes(2, address.pni().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setString(3, address.number().orElse(null));
statement.setString(4, address.username().orElse(null));
return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet)
.collect(Collectors.toSet());
}
@ -908,7 +963,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var serviceId = Optional.ofNullable(resultSet.getBytes("uuid")).map(ServiceId::parseOrNull);
final var pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull);
final var number = Optional.ofNullable(resultSet.getString("number"));
return new RecipientAddress(serviceId, pni, number);
final var username = Optional.ofNullable(resultSet.getString("username"));
return new RecipientAddress(serviceId, pni, number, username);
}
private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException {

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.recipients;
import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional;
@ -15,6 +16,8 @@ public interface RecipientTrustedResolver {
RecipientId resolveRecipientTrusted(Optional<ACI> aci, Optional<PNI> pni, Optional<String> number);
RecipientId resolveRecipientTrusted(ServiceId serviceId, String username);
class RecipientTrustedResolverWrapper implements RecipientTrustedResolver {
private final Supplier<RecipientTrustedResolver> recipientTrustedResolverSupplier;
@ -39,5 +42,10 @@ public interface RecipientTrustedResolver {
) {
return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(aci, pni, number);
}
@Override
public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) {
return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(serviceId, username);
}
}
}

View file

@ -55,10 +55,12 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
for (var r : recipients) {
final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
final var profile = r.getProfile() == null ? Profile.newBuilder().build() : r.getProfile();
writer.println("Number: {} Name: {} Profile name: {} Color: {} Blocked: {} Message expiration: {}",
writer.println(
"Number: {} Name: {} Profile name: {} Username: {} Color: {} Blocked: {} Message expiration: {}",
r.getAddress().getLegacyIdentifier(),
contact.getName(),
profile.getDisplayName(),
r.getAddress().username().orElse(""),
contact.getColor(),
contact.isBlocked(),
contact.getMessageExpirationTime() == 0
@ -72,6 +74,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
return new JsonContact(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
address.username().orElse(null),
contact.getName(),
contact.getColor(),
contact.isBlocked(),
@ -96,6 +99,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
private record JsonContact(
String number,
String uuid,
String username,
String name,
String color,
boolean isBlocked,

View file

@ -1,12 +1,17 @@
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.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import java.io.IOException;
@ -21,6 +26,11 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
public void attachToSubparser(final Subparser subparser) {
subparser.help("Update the account attributes on the signal server.");
subparser.addArgument("-n", "--device-name").help("Specify a name to describe this device.");
var mut = subparser.addMutuallyExclusiveGroup();
mut.addArgument("-u", "--username").help("Specify a username that can then be used to contact this account.");
mut.addArgument("--delete-username")
.action(Arguments.storeTrue())
.help("Delete the username associated with this account.");
}
@Override
@ -33,5 +43,34 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
} catch (IOException e) {
throw new IOErrorException("UpdateAccount error: " + e.getMessage(), e);
}
var username = ns.getString("username");
if (username != null) {
try {
final var newUsername = m.setUsername(username);
if (outputWriter instanceof PlainTextWriter w) {
w.println("Your new username: {}", newUsername);
} else if (outputWriter instanceof JsonWriter w) {
w.write(new JsonAccountResponse(newUsername));
}
} catch (IOException e) {
throw new IOErrorException("Failed to set username: " + e.getMessage(), e);
} catch (InvalidUsernameException e) {
throw new UserErrorException("Invalid username: " + e.getMessage(), e);
}
}
var deleteUsername = Boolean.TRUE.equals(ns.getBoolean("delete-username"));
if (deleteUsername) {
try {
m.deleteUsername();
} catch (IOException e) {
throw new IOErrorException("Failed to delete username: " + e.getMessage(), e);
}
}
}
private record JsonAccountResponse(
String username
) {}
}

View file

@ -10,6 +10,7 @@ import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -151,6 +152,16 @@ public class DbusManagerImpl implements Manager {
updateProfile.isDeleteAvatar());
}
@Override
public String setUsername(final String username) throws IOException, InvalidUsernameException {
throw new UnsupportedOperationException();
}
@Override
public void deleteUsername() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void unregister() throws IOException {
signal.unregister();