]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/syncStorage/ContactRecordProcessor.java
5bae64c82969ba29e69626dceb106739d69e2c36
[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 final var pniSignatureVerified = remote.isPniSignatureVerified() || local.isPniSignatureVerified();
184
185 final var mergedBuilder = new SignalContactRecord.Builder(remote.getId().getRaw(), aci, unknownFields).setE164(
186 e164)
187 .setPni(pni)
188 .setProfileGivenName(profileGivenName)
189 .setProfileFamilyName(profileFamilyName)
190 .setSystemGivenName(systemGivenName)
191 .setSystemFamilyName(systemFamilyName)
192 .setSystemNickname(systemNickname)
193 .setProfileKey(profileKey)
194 .setUsername(username)
195 .setIdentityState(identityState)
196 .setIdentityKey(identityKey)
197 .setBlocked(blocked)
198 .setProfileSharingEnabled(profileSharing)
199 .setArchived(archived)
200 .setForcedUnread(forcedUnread)
201 .setMuteUntil(muteUntil)
202 .setHideStory(hideStory)
203 .setUnregisteredTimestamp(unregisteredTimestamp)
204 .setHidden(hidden)
205 .setPniSignatureVerified(pniSignatureVerified);
206 final var merged = mergedBuilder.build();
207
208 final var matchesRemote = doProtosMatch(merged, remote);
209 if (matchesRemote) {
210 return remote;
211 }
212
213 final var matchesLocal = doProtosMatch(merged, local);
214 if (matchesLocal) {
215 return local;
216 }
217
218 return mergedBuilder.setId(KeyUtils.createRawStorageId()).build();
219 }
220
221 @Override
222 protected void insertLocal(SignalContactRecord record) throws SQLException {
223 StorageRecordUpdate<SignalContactRecord> update = new StorageRecordUpdate<>(null, record);
224 updateLocal(update);
225 }
226
227 @Override
228 protected void updateLocal(StorageRecordUpdate<SignalContactRecord> update) throws SQLException {
229 final var contactRecord = update.newRecord();
230 final var address = getRecipientAddress(contactRecord);
231 final var recipientId = account.getRecipientStore().resolveRecipientTrusted(connection, address);
232 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
233
234 final var contact = recipient.getContact();
235 final var blocked = contact != null && contact.isBlocked();
236 final var profileShared = contact != null && contact.isProfileSharingEnabled();
237 final var archived = contact != null && contact.isArchived();
238 final var hidden = contact != null && contact.isHidden();
239 final var hideStory = contact != null && contact.hideStory();
240 final var muteUntil = contact == null ? 0 : contact.muteUntil();
241 final var unregisteredTimestamp = contact == null || contact.unregisteredTimestamp() == null
242 ? 0
243 : contact.unregisteredTimestamp();
244 final var contactGivenName = contact == null ? null : contact.givenName();
245 final var contactFamilyName = contact == null ? null : contact.familyName();
246 final var contactNickName = contact == null ? null : contact.nickName();
247 if (blocked != contactRecord.isBlocked()
248 || profileShared != contactRecord.isProfileSharingEnabled()
249 || archived != contactRecord.isArchived()
250 || hidden != contactRecord.isHidden()
251 || hideStory != contactRecord.shouldHideStory()
252 || muteUntil != contactRecord.getMuteUntil()
253 || unregisteredTimestamp != contactRecord.getUnregisteredTimestamp()
254 || !Objects.equals(contactRecord.getSystemGivenName().orElse(null), contactGivenName)
255 || !Objects.equals(contactRecord.getSystemFamilyName().orElse(null), contactFamilyName)
256 || !Objects.equals(contactRecord.getSystemNickname().orElse(null), contactNickName)) {
257 logger.debug("Storing new or updated contact {}", recipientId);
258 final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
259 final var newContact = contactBuilder.withIsBlocked(contactRecord.isBlocked())
260 .withIsProfileSharingEnabled(contactRecord.isProfileSharingEnabled())
261 .withIsArchived(contactRecord.isArchived())
262 .withIsHidden(contactRecord.isHidden())
263 .withMuteUntil(contactRecord.getMuteUntil())
264 .withHideStory(contactRecord.shouldHideStory())
265 .withGivenName(contactRecord.getSystemGivenName().orElse(null))
266 .withFamilyName(contactRecord.getSystemFamilyName().orElse(null))
267 .withNickName(contactRecord.getSystemNickname().orElse(null))
268 .withUnregisteredTimestamp(contactRecord.getUnregisteredTimestamp() == 0
269 ? null
270 : contactRecord.getUnregisteredTimestamp());
271 account.getRecipientStore().storeContact(connection, recipientId, newContact.build());
272 }
273
274 final var profile = recipient.getProfile();
275 final var profileGivenName = profile == null ? null : profile.getGivenName();
276 final var profileFamilyName = profile == null ? null : profile.getFamilyName();
277 if (!Objects.equals(contactRecord.getProfileGivenName().orElse(null), profileGivenName) || !Objects.equals(
278 contactRecord.getProfileFamilyName().orElse(null),
279 profileFamilyName)) {
280 final var profileBuilder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
281 final var newProfile = profileBuilder.withGivenName(contactRecord.getProfileGivenName().orElse(null))
282 .withFamilyName(contactRecord.getProfileFamilyName().orElse(null))
283 .build();
284 account.getRecipientStore().storeProfile(connection, recipientId, newProfile);
285 }
286 if (contactRecord.getProfileKey().isPresent()) {
287 try {
288 logger.trace("Storing profile key {}", recipientId);
289 final var profileKey = new ProfileKey(contactRecord.getProfileKey().get());
290 account.getRecipientStore().storeProfileKey(connection, recipientId, profileKey);
291 } catch (InvalidInputException e) {
292 logger.warn("Received invalid contact profile key from storage");
293 }
294 }
295 if (contactRecord.getIdentityKey().isPresent() && contactRecord.getAci().isPresent()) {
296 try {
297 logger.trace("Storing identity key {}", recipientId);
298 final var identityKey = new IdentityKey(contactRecord.getIdentityKey().get());
299 account.getIdentityKeyStore()
300 .saveIdentity(connection, contactRecord.getAci().orElse(null), identityKey);
301
302 final var trustLevel = StorageSyncModels.remoteToLocal(contactRecord.getIdentityState());
303 if (trustLevel != null) {
304 account.getIdentityKeyStore()
305 .setIdentityTrustLevel(connection,
306 contactRecord.getAci().orElse(null),
307 identityKey,
308 trustLevel);
309 }
310 } catch (InvalidKeyException e) {
311 logger.warn("Received invalid contact identity key from storage");
312 }
313 }
314 account.getRecipientStore()
315 .storeStorageRecord(connection, recipientId, contactRecord.getId(), contactRecord.toProto().encode());
316 }
317
318 private static RecipientAddress getRecipientAddress(final SignalContactRecord contactRecord) {
319 return new RecipientAddress(contactRecord.getAci().orElse(null),
320 contactRecord.getPni().orElse(null),
321 contactRecord.getNumber().orElse(null),
322 contactRecord.getUsername().orElse(null));
323 }
324
325 @Override
326 public int compare(SignalContactRecord lhs, SignalContactRecord rhs) {
327 if ((lhs.getAci().isPresent() && Objects.equals(lhs.getAci(), rhs.getAci())) || (
328 lhs.getNumber().isPresent() && Objects.equals(lhs.getNumber(), rhs.getNumber())
329 ) || (lhs.getPni().isPresent() && Objects.equals(lhs.getPni(), rhs.getPni()))) {
330 return 0;
331 } else {
332 return 1;
333 }
334 }
335
336 private static boolean isValidE164(String value) {
337 return E164_PATTERN.matcher(value).matches();
338 }
339
340 private static boolean doProtosMatch(SignalContactRecord merged, SignalContactRecord other) {
341 return Arrays.equals(merged.toProto().encode(), other.toProto().encode());
342 }
343 }