]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Update libsignal-service-java
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / ProfileHelper.java
1 package org.asamk.signal.manager.helper;
2
3 import org.asamk.signal.manager.AvatarStore;
4 import org.asamk.signal.manager.SignalDependencies;
5 import org.asamk.signal.manager.config.ServiceConfig;
6 import org.asamk.signal.manager.storage.SignalAccount;
7 import org.asamk.signal.manager.storage.recipients.Profile;
8 import org.asamk.signal.manager.storage.recipients.RecipientId;
9 import org.asamk.signal.manager.util.IOUtils;
10 import org.asamk.signal.manager.util.ProfileUtils;
11 import org.asamk.signal.manager.util.Utils;
12 import org.signal.zkgroup.profiles.ProfileKey;
13 import org.signal.zkgroup.profiles.ProfileKeyCredential;
14 import org.slf4j.Logger;
15 import org.slf4j.LoggerFactory;
16 import org.whispersystems.libsignal.IdentityKey;
17 import org.whispersystems.libsignal.InvalidKeyException;
18 import org.whispersystems.libsignal.util.guava.Optional;
19 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
20 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
21 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
22 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
23 import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
24 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
25 import org.whispersystems.signalservice.api.services.ProfileService;
26 import org.whispersystems.signalservice.internal.ServiceResponse;
27
28 import java.io.File;
29 import java.io.IOException;
30 import java.io.OutputStream;
31 import java.nio.file.Files;
32 import java.util.Base64;
33 import java.util.Date;
34 import java.util.HashSet;
35 import java.util.List;
36 import java.util.Locale;
37 import java.util.Objects;
38 import java.util.Set;
39
40 import io.reactivex.rxjava3.core.Single;
41
42 public final class ProfileHelper {
43
44 private final static Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
45
46 private final SignalAccount account;
47 private final SignalDependencies dependencies;
48 private final AvatarStore avatarStore;
49 private final UnidentifiedAccessProvider unidentifiedAccessProvider;
50 private final SignalServiceAddressResolver addressResolver;
51
52 public ProfileHelper(
53 final SignalAccount account,
54 final SignalDependencies dependencies,
55 final AvatarStore avatarStore,
56 final UnidentifiedAccessProvider unidentifiedAccessProvider,
57 final SignalServiceAddressResolver addressResolver
58 ) {
59 this.account = account;
60 this.dependencies = dependencies;
61 this.avatarStore = avatarStore;
62 this.unidentifiedAccessProvider = unidentifiedAccessProvider;
63 this.addressResolver = addressResolver;
64 }
65
66 public Profile getRecipientProfile(RecipientId recipientId) {
67 return getRecipientProfile(recipientId, false);
68 }
69
70 public void refreshRecipientProfile(RecipientId recipientId) {
71 getRecipientProfile(recipientId, true);
72 }
73
74 public ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
75 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
76 if (profileKeyCredential != null) {
77 return profileKeyCredential;
78 }
79
80 ProfileAndCredential profileAndCredential;
81 try {
82 profileAndCredential = retrieveProfileAndCredential(recipientId,
83 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL);
84 } catch (IOException e) {
85 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
86 return null;
87 }
88
89 profileKeyCredential = profileAndCredential.getProfileKeyCredential().orNull();
90 account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
91
92 var profileKey = account.getProfileStore().getProfileKey(recipientId);
93 if (profileKey != null) {
94 final var profile = decryptProfileAndDownloadAvatar(recipientId,
95 profileKey,
96 profileAndCredential.getProfile());
97 account.getProfileStore().storeProfile(recipientId, profile);
98 }
99
100 return profileKeyCredential;
101 }
102
103 /**
104 * @param givenName if null, the previous givenName will be kept
105 * @param familyName if null, the previous familyName will be kept
106 * @param about if null, the previous about text will be kept
107 * @param aboutEmoji if null, the previous about emoji will be kept
108 * @param avatar if avatar is null the image from the local avatar store is used (if present),
109 */
110 public void setProfile(
111 String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
112 ) throws IOException {
113 setProfile(true, givenName, familyName, about, aboutEmoji, avatar);
114 }
115
116 public void setProfile(
117 boolean uploadProfile,
118 String givenName,
119 final String familyName,
120 String about,
121 String aboutEmoji,
122 Optional<File> avatar
123 ) throws IOException {
124 var profile = getRecipientProfile(account.getSelfRecipientId());
125 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
126 if (givenName != null) {
127 builder.withGivenName(givenName);
128 }
129 if (familyName != null) {
130 builder.withFamilyName(familyName);
131 }
132 if (about != null) {
133 builder.withAbout(about);
134 }
135 if (aboutEmoji != null) {
136 builder.withAboutEmoji(aboutEmoji);
137 }
138 var newProfile = builder.build();
139
140 if (uploadProfile) {
141 try (final var streamDetails = avatar == null
142 ? avatarStore.retrieveProfileAvatar(account.getSelfAddress())
143 : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) {
144 final var avatarPath = dependencies.getAccountManager()
145 .setVersionedProfile(account.getAci(),
146 account.getProfileKey(),
147 newProfile.getInternalServiceName(),
148 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
149 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
150 Optional.absent(),
151 streamDetails,
152 List.of(/* TODO */));
153 builder.withAvatarUrlPath(avatarPath.orNull());
154 newProfile = builder.build();
155 }
156 }
157
158 if (avatar != null) {
159 if (avatar.isPresent()) {
160 avatarStore.storeProfileAvatar(account.getSelfAddress(),
161 outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
162 } else {
163 avatarStore.deleteProfileAvatar(account.getSelfAddress());
164 }
165 }
166 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
167 }
168
169 private final Set<RecipientId> pendingProfileRequest = new HashSet<>();
170
171 private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
172 var profile = account.getProfileStore().getProfile(recipientId);
173
174 var now = System.currentTimeMillis();
175 // Profiles are cached for 24h before retrieving them again, unless forced
176 if (!force && profile != null && now - profile.getLastUpdateTimestamp() < 24 * 60 * 60 * 1000) {
177 return profile;
178 }
179
180 synchronized (pendingProfileRequest) {
181 if (pendingProfileRequest.contains(recipientId)) {
182 return profile;
183 }
184 pendingProfileRequest.add(recipientId);
185 }
186 final SignalServiceProfile encryptedProfile;
187 try {
188 encryptedProfile = retrieveEncryptedProfile(recipientId);
189 } finally {
190 synchronized (pendingProfileRequest) {
191 pendingProfileRequest.remove(recipientId);
192 }
193 }
194 if (encryptedProfile == null) {
195 return null;
196 }
197
198 profile = decryptProfileIfKeyKnown(recipientId, encryptedProfile);
199 account.getProfileStore().storeProfile(recipientId, profile);
200
201 return profile;
202 }
203
204 private Profile decryptProfileIfKeyKnown(
205 final RecipientId recipientId, final SignalServiceProfile encryptedProfile
206 ) {
207 var profileKey = account.getProfileStore().getProfileKey(recipientId);
208 if (profileKey == null) {
209 return new Profile(System.currentTimeMillis(),
210 null,
211 null,
212 null,
213 null,
214 null,
215 ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null),
216 ProfileUtils.getCapabilities(encryptedProfile));
217 }
218
219 return decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile);
220 }
221
222 private SignalServiceProfile retrieveEncryptedProfile(RecipientId recipientId) {
223 try {
224 return retrieveProfileAndCredential(recipientId, SignalServiceProfile.RequestType.PROFILE).getProfile();
225 } catch (IOException e) {
226 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
227 return null;
228 }
229 }
230
231 private SignalServiceProfile retrieveProfileSync(String username) throws IOException {
232 final var locale = Locale.getDefault();
233 return dependencies.getMessageReceiver().retrieveProfileByUsername(username, Optional.absent(), locale);
234 }
235
236 private ProfileAndCredential retrieveProfileAndCredential(
237 final RecipientId recipientId, final SignalServiceProfile.RequestType requestType
238 ) throws IOException {
239 final var profileAndCredential = retrieveProfileSync(recipientId, requestType);
240 final var profile = profileAndCredential.getProfile();
241
242 try {
243 var newIdentity = account.getIdentityKeyStore()
244 .saveIdentity(recipientId,
245 new IdentityKey(Base64.getDecoder().decode(profile.getIdentityKey())),
246 new Date());
247
248 if (newIdentity) {
249 account.getSessionStore().archiveSessions(recipientId);
250 }
251 } catch (InvalidKeyException ignored) {
252 logger.warn("Got invalid identity key in profile for {}",
253 addressResolver.resolveSignalServiceAddress(recipientId).getIdentifier());
254 }
255 return profileAndCredential;
256 }
257
258 private Profile decryptProfileAndDownloadAvatar(
259 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
260 ) {
261 final var avatarPath = encryptedProfile.getAvatar();
262 downloadProfileAvatar(recipientId, avatarPath, profileKey);
263
264 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
265 }
266
267 public void downloadProfileAvatar(
268 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
269 ) {
270 var profile = account.getProfileStore().getProfile(recipientId);
271 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
272 downloadProfileAvatar(addressResolver.resolveSignalServiceAddress(recipientId), avatarPath, profileKey);
273 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
274 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
275 }
276 }
277
278 private ProfileAndCredential retrieveProfileSync(
279 RecipientId recipientId, SignalServiceProfile.RequestType requestType
280 ) throws IOException {
281 try {
282 return retrieveProfile(recipientId, requestType).blockingGet();
283 } catch (RuntimeException e) {
284 if (e.getCause() instanceof PushNetworkException) {
285 throw (PushNetworkException) e.getCause();
286 } else if (e.getCause() instanceof NotFoundException) {
287 throw (NotFoundException) e.getCause();
288 } else {
289 throw new IOException(e);
290 }
291 }
292 }
293
294 private Single<ProfileAndCredential> retrieveProfile(
295 RecipientId recipientId, SignalServiceProfile.RequestType requestType
296 ) {
297 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
298 var profileKey = Optional.fromNullable(account.getProfileStore().getProfileKey(recipientId));
299
300 final var address = addressResolver.resolveSignalServiceAddress(recipientId);
301 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
302 }
303
304 private Single<ProfileAndCredential> retrieveProfile(
305 SignalServiceAddress address,
306 Optional<ProfileKey> profileKey,
307 Optional<UnidentifiedAccess> unidentifiedAccess,
308 SignalServiceProfile.RequestType requestType
309 ) {
310 var profileService = dependencies.getProfileService();
311
312 Single<ServiceResponse<ProfileAndCredential>> responseSingle;
313 final var locale = Locale.getDefault();
314 try {
315 responseSingle = profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale);
316 } catch (NoClassDefFoundError e) {
317 // Native zkgroup lib not available for ProfileKey
318 responseSingle = profileService.getProfile(address,
319 Optional.absent(),
320 unidentifiedAccess,
321 requestType,
322 locale);
323 }
324
325 return responseSingle.map(pair -> {
326 var processor = new ProfileService.ProfileResponseProcessor(pair);
327 if (processor.hasResult()) {
328 return processor.getResult();
329 } else if (processor.notFound()) {
330 throw new NotFoundException("Profile not found");
331 } else {
332 throw pair.getExecutionError()
333 .or(pair.getApplicationError())
334 .or(new IOException("Unknown error while retrieving profile"));
335 }
336 });
337 }
338
339 private void downloadProfileAvatar(
340 SignalServiceAddress address, String avatarPath, ProfileKey profileKey
341 ) {
342 if (avatarPath == null) {
343 try {
344 avatarStore.deleteProfileAvatar(address);
345 } catch (IOException e) {
346 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
347 }
348 return;
349 }
350
351 try {
352 avatarStore.storeProfileAvatar(address,
353 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
354 } catch (Throwable e) {
355 if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
356 Thread.currentThread().interrupt();
357 }
358 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
359 }
360 }
361
362 private void retrieveProfileAvatar(
363 String avatarPath, ProfileKey profileKey, OutputStream outputStream
364 ) throws IOException {
365 var tmpFile = IOUtils.createTempFile();
366 try (var input = dependencies.getMessageReceiver()
367 .retrieveProfileAvatar(avatarPath,
368 tmpFile,
369 profileKey,
370 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
371 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
372 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
373 } finally {
374 try {
375 Files.delete(tmpFile.toPath());
376 } catch (IOException e) {
377 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
378 tmpFile,
379 e.getMessage());
380 }
381 }
382 }
383
384 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
385 var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipientId);
386
387 if (unidentifiedAccess.isPresent()) {
388 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
389 }
390
391 return Optional.absent();
392 }
393 }