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