]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/syncStorage/ContactRecordProcessor.java
7598b5ea57d2b1d1ea30567169eecd79036f7e05
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / syncStorage / ContactRecordProcessor.java
1 package org.asamk.signal.manager.syncStorage;
2
3 import org.asamk.signal.manager.api.Contact;
4 import org.asamk.signal.manager.api.Profile;
5 import org.asamk.signal.manager.internal.JobExecutor;
6 import org.asamk.signal.manager.jobs.DownloadProfileJob;
7 import org.asamk.signal.manager.jobs.RefreshRecipientsJob;
8 import org.asamk.signal.manager.storage.SignalAccount;
9 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
10 import org.asamk.signal.manager.util.KeyUtils;
11 import org.signal.libsignal.protocol.IdentityKey;
12 import org.signal.libsignal.protocol.InvalidKeyException;
13 import org.signal.libsignal.zkgroup.InvalidInputException;
14 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
15 import org.slf4j.Logger;
16 import org.slf4j.LoggerFactory;
17 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
18 import org.whispersystems.signalservice.api.push.ServiceId.PNI;
19 import org.whispersystems.signalservice.api.storage.SignalContactRecord;
20 import org.whispersystems.signalservice.api.util.OptionalUtil;
21 import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
22
23 import java.sql.Connection;
24 import java.sql.SQLException;
25 import java.util.Arrays;
26 import java.util.Objects;
27 import java.util.Optional;
28 import java.util.regex.Pattern;
29
30 public class ContactRecordProcessor extends DefaultStorageRecordProcessor<SignalContactRecord> {
31
32 private static final Logger logger = LoggerFactory.getLogger(ContactRecordProcessor.class);
33
34 private static final Pattern E164_PATTERN = Pattern.compile("^\\+[1-9]\\d{0,18}$");
35
36 private final ACI selfAci;
37 private final PNI selfPni;
38 private final String selfNumber;
39 private final SignalAccount account;
40 private final Connection connection;
41 private final JobExecutor jobExecutor;
42
43 public ContactRecordProcessor(SignalAccount account, Connection connection, final JobExecutor jobExecutor) {
44 this.account = account;
45 this.connection = connection;
46 this.jobExecutor = jobExecutor;
47 this.selfAci = account.getAci();
48 this.selfPni = account.getPni();
49 this.selfNumber = account.getNumber();
50 }
51
52 /**
53 * Error cases:
54 * - You can't have a contact record without an ACI or PNI.
55 * - You can't have a contact record for yourself. That should be an account record.
56 */
57 @Override
58 protected boolean isInvalid(SignalContactRecord remote) {
59 boolean hasAci = remote.getAci().isPresent() && remote.getAci().get().isValid();
60 boolean hasPni = remote.getPni().isPresent() && remote.getPni().get().isValid();
61
62 if (!hasAci && !hasPni) {
63 logger.debug("Found a ContactRecord with neither an ACI nor a PNI -- marking as invalid.");
64 return true;
65 } else if (selfAci != null && selfAci.equals(remote.getAci().orElse(null)) || (
66 selfPni != null && selfPni.equals(remote.getPni().orElse(null))
67 ) || (selfNumber != null && selfNumber.equals(remote.getNumber().orElse(null)))) {
68 logger.debug("Found a ContactRecord for ourselves -- marking as invalid.");
69 return true;
70 } else if (remote.getNumber().isPresent() && !isValidE164(remote.getNumber().get())) {
71 logger.debug("Found a record with an invalid E164. Marking as invalid.");
72 return true;
73 } else {
74 return false;
75 }
76 }
77
78 @Override
79 protected Optional<SignalContactRecord> getMatching(SignalContactRecord remote) throws SQLException {
80 final var address = getRecipientAddress(remote);
81 final var recipientId = account.getRecipientStore().resolveRecipient(connection, address);
82 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
83
84 final var identifier = recipient.getAddress().getIdentifier();
85 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, identifier);
86 final var storageId = account.getRecipientStore().getStorageId(connection, recipientId);
87
88 return Optional.of(StorageSyncModels.localToRemoteRecord(recipient, identity, storageId.getRaw())
89 .getContact()
90 .get());
91 }
92
93 @Override
94 protected SignalContactRecord merge(
95 SignalContactRecord remote, SignalContactRecord local
96 ) {
97 String profileGivenName;
98 String profileFamilyName;
99 if (remote.getProfileGivenName().isPresent() || remote.getProfileFamilyName().isPresent()) {
100 profileGivenName = remote.getProfileGivenName().orElse("");
101 profileFamilyName = remote.getProfileFamilyName().orElse("");
102 } else {
103 profileGivenName = local.getProfileGivenName().orElse("");
104 profileFamilyName = local.getProfileFamilyName().orElse("");
105 }
106
107 IdentityState identityState;
108 byte[] identityKey;
109 if (remote.getIdentityKey().isPresent() && (
110 remote.getIdentityState() != local.getIdentityState()
111 || local.getIdentityKey().isEmpty()
112 || !account.isPrimaryDevice()
113
114 )) {
115 identityState = remote.getIdentityState();
116 identityKey = remote.getIdentityKey().get();
117 } else {
118 identityState = local.getIdentityState();
119 identityKey = local.getIdentityKey().orElse(null);
120 }
121
122 if (local.getAci().isPresent()
123 && local.getIdentityKey().isPresent()
124 && remote.getIdentityKey().isPresent()
125 && !Arrays.equals(local.getIdentityKey().get(), remote.getIdentityKey().get())) {
126 logger.debug("The local and remote identity keys do not match for {}. Enqueueing a profile fetch.",
127 local.getAci().orElse(null));
128 final var address = getRecipientAddress(local);
129 jobExecutor.enqueueJob(new DownloadProfileJob(address));
130 }
131
132 final var e164sMatchButPnisDont = local.getNumber().isPresent()
133 && local.getNumber()
134 .get()
135 .equals(remote.getNumber().orElse(null))
136 && local.getPni().isPresent()
137 && remote.getPni().isPresent()
138 && !local.getPni().get().equals(remote.getPni().get());
139
140 final var pnisMatchButE164sDont = local.getPni().isPresent()
141 && local.getPni()
142 .get()
143 .equals(remote.getPni().orElse(null))
144 && local.getNumber().isPresent()
145 && remote.getNumber().isPresent()
146 && !local.getNumber().get().equals(remote.getNumber().get());
147
148 PNI pni;
149 String e164;
150 if (!account.isPrimaryDevice() && (e164sMatchButPnisDont || pnisMatchButE164sDont)) {
151 if (e164sMatchButPnisDont) {
152 logger.debug("Matching E164s, but the PNIs differ! Trusting our local pair.");
153 } else if (pnisMatchButE164sDont) {
154 logger.debug("Matching PNIs, but the E164s differ! Trusting our local pair.");
155 }
156 jobExecutor.enqueueJob(new RefreshRecipientsJob());
157 pni = local.getPni().get();
158 e164 = local.getNumber().get();
159 } else {
160 pni = OptionalUtil.or(remote.getPni(), local.getPni()).orElse(null);
161 e164 = OptionalUtil.or(remote.getNumber(), local.getNumber()).orElse(null);
162 }
163
164 final var unknownFields = remote.serializeUnknownFields();
165 final var aci = local.getAci().isEmpty() ? remote.getAci().orElse(null) : local.getAci().get();
166 final var profileKey = OptionalUtil.or(remote.getProfileKey(), local.getProfileKey()).orElse(null);
167 final var username = OptionalUtil.or(remote.getUsername(), local.getUsername()).orElse("");
168 final var blocked = remote.isBlocked();
169 final var profileSharing = remote.isProfileSharingEnabled();
170 final var archived = remote.isArchived();
171 final var forcedUnread = remote.isForcedUnread();
172 final var muteUntil = remote.getMuteUntil();
173 final var hideStory = remote.shouldHideStory();
174 final var unregisteredTimestamp = remote.getUnregisteredTimestamp();
175 final var hidden = remote.isHidden();
176 final var systemGivenName = account.isPrimaryDevice()
177 ? local.getSystemGivenName().orElse("")
178 : remote.getSystemGivenName().orElse("");
179 final var systemFamilyName = account.isPrimaryDevice()
180 ? local.getSystemFamilyName().orElse("")
181 : remote.getSystemFamilyName().orElse("");
182 final var systemNickname = remote.getSystemNickname().orElse("");
183
184 final var mergedBuilder = new SignalContactRecord.Builder(remote.getId().getRaw(), aci, unknownFields).setE164(
185 e164)
186 .setPni(pni)
187 .setProfileGivenName(profileGivenName)
188 .setProfileFamilyName(profileFamilyName)
189 .setSystemGivenName(systemGivenName)
190 .setSystemFamilyName(systemFamilyName)
191 .setSystemNickname(systemNickname)
192 .setProfileKey(profileKey)
193 .setUsername(username)
194 .setIdentityState(identityState)
195 .setIdentityKey(identityKey)
196 .setBlocked(blocked)
197 .setProfileSharingEnabled(profileSharing)
198 .setArchived(archived)
199 .setForcedUnread(forcedUnread)
200 .setMuteUntil(muteUntil)
201 .setHideStory(hideStory)
202 .setUnregisteredTimestamp(unregisteredTimestamp)
203 .setHidden(hidden);
204 final var merged = mergedBuilder.build();
205
206 final var matchesRemote = doProtosMatch(merged, remote);
207 if (matchesRemote) {
208 return remote;
209 }
210
211 final var matchesLocal = doProtosMatch(merged, local);
212 if (matchesLocal) {
213 return local;
214 }
215
216 return mergedBuilder.setId(KeyUtils.createRawStorageId()).build();
217 }
218
219 @Override
220 protected void insertLocal(SignalContactRecord record) throws SQLException {
221 StorageRecordUpdate<SignalContactRecord> update = new StorageRecordUpdate<>(null, record);
222 updateLocal(update);
223 }
224
225 @Override
226 protected void updateLocal(StorageRecordUpdate<SignalContactRecord> update) throws SQLException {
227 final var contactRecord = update.newRecord();
228 final var address = getRecipientAddress(contactRecord);
229 final var recipientId = account.getRecipientStore().resolveRecipientTrusted(connection, address);
230 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
231
232 final var contact = recipient.getContact();
233 final var blocked = contact != null && contact.isBlocked();
234 final var profileShared = contact != null && contact.isProfileSharingEnabled();
235 final var archived = contact != null && contact.isArchived();
236 final var hidden = contact != null && contact.isHidden();
237 final var hideStory = contact != null && contact.hideStory();
238 final var muteUntil = contact == null ? 0 : contact.muteUntil();
239 final var unregisteredTimestamp = contact == null || contact.unregisteredTimestamp() == null
240 ? 0
241 : contact.unregisteredTimestamp();
242 final var contactGivenName = contact == null ? null : contact.givenName();
243 final var contactFamilyName = contact == null ? null : contact.familyName();
244 final var contactNickName = contact == null ? null : contact.nickName();
245 if (blocked != contactRecord.isBlocked()
246 || profileShared != contactRecord.isProfileSharingEnabled()
247 || archived != contactRecord.isArchived()
248 || hidden != contactRecord.isHidden()
249 || hideStory != contactRecord.shouldHideStory()
250 || muteUntil != contactRecord.getMuteUntil()
251 || unregisteredTimestamp != contactRecord.getUnregisteredTimestamp()
252 || !Objects.equals(contactRecord.getSystemGivenName().orElse(null), contactGivenName)
253 || !Objects.equals(contactRecord.getSystemFamilyName().orElse(null), contactFamilyName)
254 || !Objects.equals(contactRecord.getSystemNickname().orElse(null), contactNickName)) {
255 logger.debug("Storing new or updated contact {}", recipientId);
256 final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
257 final var newContact = contactBuilder.withIsBlocked(contactRecord.isBlocked())
258 .withIsProfileSharingEnabled(contactRecord.isProfileSharingEnabled())
259 .withIsArchived(contactRecord.isArchived())
260 .withIsHidden(contactRecord.isHidden())
261 .withMuteUntil(contactRecord.getMuteUntil())
262 .withHideStory(contactRecord.shouldHideStory())
263 .withGivenName(contactRecord.getSystemGivenName().orElse(null))
264 .withFamilyName(contactRecord.getSystemFamilyName().orElse(null))
265 .withNickName(contactRecord.getSystemNickname().orElse(null))
266 .withUnregisteredTimestamp(contactRecord.getUnregisteredTimestamp() == 0
267 ? null
268 : contactRecord.getUnregisteredTimestamp());
269 account.getRecipientStore().storeContact(connection, recipientId, newContact.build());
270 }
271
272 final var profile = recipient.getProfile();
273 final var profileGivenName = profile == null ? null : profile.getGivenName();
274 final var profileFamilyName = profile == null ? null : profile.getFamilyName();
275 if (!Objects.equals(contactRecord.getProfileGivenName().orElse(null), profileGivenName) || !Objects.equals(
276 contactRecord.getProfileFamilyName().orElse(null),
277 profileFamilyName)) {
278 final var profileBuilder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
279 final var newProfile = profileBuilder.withGivenName(contactRecord.getProfileGivenName().orElse(null))
280 .withFamilyName(contactRecord.getProfileFamilyName().orElse(null))
281 .build();
282 account.getRecipientStore().storeProfile(connection, recipientId, newProfile);
283 }
284 if (contactRecord.getProfileKey().isPresent()) {
285 try {
286 logger.trace("Storing profile key {}", recipientId);
287 final var profileKey = new ProfileKey(contactRecord.getProfileKey().get());
288 account.getRecipientStore().storeProfileKey(connection, recipientId, profileKey);
289 } catch (InvalidInputException e) {
290 logger.warn("Received invalid contact profile key from storage");
291 }
292 }
293 if (contactRecord.getIdentityKey().isPresent() && contactRecord.getAci().isPresent()) {
294 try {
295 logger.trace("Storing identity key {}", recipientId);
296 final var identityKey = new IdentityKey(contactRecord.getIdentityKey().get());
297 account.getIdentityKeyStore()
298 .saveIdentity(connection, contactRecord.getAci().orElse(null), identityKey);
299
300 final var trustLevel = StorageSyncModels.remoteToLocal(contactRecord.getIdentityState());
301 if (trustLevel != null) {
302 account.getIdentityKeyStore()
303 .setIdentityTrustLevel(connection,
304 contactRecord.getAci().orElse(null),
305 identityKey,
306 trustLevel);
307 }
308 } catch (InvalidKeyException e) {
309 logger.warn("Received invalid contact identity key from storage");
310 }
311 }
312 account.getRecipientStore()
313 .storeStorageRecord(connection, recipientId, contactRecord.getId(), contactRecord.toProto().encode());
314 }
315
316 private static RecipientAddress getRecipientAddress(final SignalContactRecord contactRecord) {
317 return new RecipientAddress(contactRecord.getAci().orElse(null),
318 contactRecord.getPni().orElse(null),
319 contactRecord.getNumber().orElse(null),
320 contactRecord.getUsername().orElse(null));
321 }
322
323 @Override
324 public int compare(SignalContactRecord lhs, SignalContactRecord rhs) {
325 if ((lhs.getAci().isPresent() && Objects.equals(lhs.getAci(), rhs.getAci())) || (
326 lhs.getNumber().isPresent() && Objects.equals(lhs.getNumber(), rhs.getNumber())
327 ) || (lhs.getPni().isPresent() && Objects.equals(lhs.getPni(), rhs.getPni()))) {
328 return 0;
329 } else {
330 return 1;
331 }
332 }
333
334 private static boolean isValidE164(String value) {
335 return E164_PATTERN.matcher(value).matches();
336 }
337
338 private static boolean doProtosMatch(SignalContactRecord merged, SignalContactRecord other) {
339 return Arrays.equals(merged.toProto().encode(), other.toProto().encode());
340 }
341 }