]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
4bc00317e65d6acda3c88a0bffa5df5ab94b5178
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / ProfileHelper.java
1 package org.asamk.signal.manager.helper;
2
3 import com.google.protobuf.InvalidProtocolBufferException;
4
5 import org.asamk.signal.manager.SignalDependencies;
6 import org.asamk.signal.manager.config.ServiceConfig;
7 import org.asamk.signal.manager.groups.GroupNotFoundException;
8 import org.asamk.signal.manager.groups.NotAGroupMemberException;
9 import org.asamk.signal.manager.storage.SignalAccount;
10 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
11 import org.asamk.signal.manager.storage.recipients.Profile;
12 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
13 import org.asamk.signal.manager.storage.recipients.RecipientId;
14 import org.asamk.signal.manager.util.IOUtils;
15 import org.asamk.signal.manager.util.KeyUtils;
16 import org.asamk.signal.manager.util.ProfileUtils;
17 import org.asamk.signal.manager.util.Utils;
18 import org.signal.libsignal.protocol.IdentityKey;
19 import org.signal.libsignal.protocol.InvalidKeyException;
20 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
21 import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredential;
22 import org.slf4j.Logger;
23 import org.slf4j.LoggerFactory;
24 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
25 import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
26 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
27 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
28 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
29 import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
30 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
31 import org.whispersystems.signalservice.api.services.ProfileService;
32 import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
33
34 import java.io.File;
35 import java.io.IOException;
36 import java.io.OutputStream;
37 import java.nio.file.Files;
38 import java.util.Base64;
39 import java.util.Date;
40 import java.util.List;
41 import java.util.Locale;
42 import java.util.Objects;
43 import java.util.Optional;
44 import java.util.Set;
45
46 import io.reactivex.rxjava3.core.Flowable;
47 import io.reactivex.rxjava3.core.Maybe;
48 import io.reactivex.rxjava3.core.Single;
49
50 public final class ProfileHelper {
51
52 private final static Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
53
54 private final SignalAccount account;
55 private final SignalDependencies dependencies;
56 private final Context context;
57
58 public ProfileHelper(final Context context) {
59 this.account = context.getAccount();
60 this.dependencies = context.getDependencies();
61 this.context = context;
62 }
63
64 public void rotateProfileKey() throws IOException {
65 var profileKey = KeyUtils.createProfileKey();
66 account.setProfileKey(profileKey);
67 context.getAccountHelper().updateAccountAttributes();
68 setProfile(true, true, null, null, null, null, null);
69 // TODO update profile key in storage
70
71 final var recipientIds = account.getRecipientStore().getRecipientIdsWithEnabledProfileSharing();
72 for (final var recipientId : recipientIds) {
73 context.getSendHelper().sendProfileKey(recipientId);
74 }
75
76 final var selfRecipientId = account.getSelfRecipientId();
77 final var activeGroupIds = account.getGroupStore()
78 .getGroups()
79 .stream()
80 .filter(g -> g instanceof GroupInfoV2 && g.isMember(selfRecipientId))
81 .map(g -> (GroupInfoV2) g)
82 .map(GroupInfoV2::getGroupId)
83 .toList();
84 for (final var groupId : activeGroupIds) {
85 try {
86 context.getGroupHelper().updateGroupProfileKey(groupId);
87 } catch (GroupNotFoundException | NotAGroupMemberException | IOException e) {
88 logger.warn("Failed to update group profile key: {}", e.getMessage());
89 }
90 }
91 }
92
93 public Profile getRecipientProfile(RecipientId recipientId) {
94 return getRecipientProfile(recipientId, false);
95 }
96
97 public void refreshRecipientProfile(RecipientId recipientId) {
98 getRecipientProfile(recipientId, true);
99 }
100
101 public List<ProfileKeyCredential> getRecipientProfileKeyCredential(List<RecipientId> recipientIds) {
102 try {
103 account.getRecipientStore().setBulkUpdating(true);
104 final var profileFetches = Flowable.fromIterable(recipientIds)
105 .filter(recipientId -> account.getProfileStore().getProfileKeyCredential(recipientId) == null)
106 .map(recipientId -> retrieveProfile(recipientId,
107 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete());
108 Maybe.merge(profileFetches, 10).blockingSubscribe();
109 } finally {
110 account.getRecipientStore().setBulkUpdating(false);
111 }
112
113 return recipientIds.stream().map(r -> account.getProfileStore().getProfileKeyCredential(r)).toList();
114 }
115
116 public ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
117 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
118 if (profileKeyCredential != null) {
119 return profileKeyCredential;
120 }
121
122 try {
123 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL));
124 } catch (IOException e) {
125 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
126 return null;
127 }
128
129 return account.getProfileStore().getProfileKeyCredential(recipientId);
130 }
131
132 /**
133 * @param givenName if null, the previous givenName will be kept
134 * @param familyName if null, the previous familyName will be kept
135 * @param about if null, the previous about text will be kept
136 * @param aboutEmoji if null, the previous about emoji will be kept
137 * @param avatar if avatar is null the image from the local avatar store is used (if present),
138 */
139 public void setProfile(
140 String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
141 ) throws IOException {
142 setProfile(true, false, givenName, familyName, about, aboutEmoji, avatar);
143 }
144
145 public void setProfile(
146 boolean uploadProfile,
147 boolean forceUploadAvatar,
148 String givenName,
149 final String familyName,
150 String about,
151 String aboutEmoji,
152 Optional<File> avatar
153 ) throws IOException {
154 var profile = getSelfProfile();
155 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
156 if (givenName != null) {
157 builder.withGivenName(givenName);
158 }
159 if (familyName != null) {
160 builder.withFamilyName(familyName);
161 }
162 if (about != null) {
163 builder.withAbout(about);
164 }
165 if (aboutEmoji != null) {
166 builder.withAboutEmoji(aboutEmoji);
167 }
168 var newProfile = builder.build();
169
170 if (uploadProfile) {
171 final var streamDetails = avatar != null && avatar.isPresent()
172 ? Utils.createStreamDetailsFromFile(avatar.get())
173 : forceUploadAvatar && avatar == null ? context.getAvatarStore()
174 .retrieveProfileAvatar(account.getSelfRecipientAddress()) : null;
175 try (streamDetails) {
176 final var avatarUploadParams = streamDetails != null
177 ? AvatarUploadParams.forAvatar(streamDetails)
178 : avatar == null ? AvatarUploadParams.unchanged(true) : AvatarUploadParams.unchanged(false);
179 final var paymentsAddress = Optional.ofNullable(newProfile.getPaymentAddress()).map(data -> {
180 try {
181 return SignalServiceProtos.PaymentAddress.parseFrom(data);
182 } catch (InvalidProtocolBufferException e) {
183 return null;
184 }
185 });
186 logger.debug("Uploading new profile");
187 final var avatarPath = dependencies.getAccountManager()
188 .setVersionedProfile(account.getAci(),
189 account.getProfileKey(),
190 newProfile.getInternalServiceName(),
191 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
192 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
193 paymentsAddress,
194 avatarUploadParams,
195 List.of(/* TODO implement support for badges */));
196 if (!avatarUploadParams.keepTheSame) {
197 builder.withAvatarUrlPath(avatarPath.orElse(null));
198 }
199 newProfile = builder.build();
200 }
201 }
202
203 if (avatar != null) {
204 if (avatar.isPresent()) {
205 context.getAvatarStore()
206 .storeProfileAvatar(account.getSelfRecipientAddress(),
207 outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
208 } else {
209 context.getAvatarStore().deleteProfileAvatar(account.getSelfRecipientAddress());
210 }
211 }
212 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
213 }
214
215 public Profile getSelfProfile() {
216 return getRecipientProfile(account.getSelfRecipientId());
217 }
218
219 public List<Profile> getRecipientProfile(List<RecipientId> recipientIds) {
220 try {
221 account.getRecipientStore().setBulkUpdating(true);
222 final var profileFetches = Flowable.fromIterable(recipientIds)
223 .filter(recipientId -> isProfileRefreshRequired(account.getProfileStore().getProfile(recipientId)))
224 .map(recipientId -> retrieveProfile(recipientId,
225 SignalServiceProfile.RequestType.PROFILE).onErrorComplete());
226 Maybe.merge(profileFetches, 10).blockingSubscribe();
227 } finally {
228 account.getRecipientStore().setBulkUpdating(false);
229 }
230
231 return recipientIds.stream().map(r -> account.getProfileStore().getProfile(r)).toList();
232 }
233
234 private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
235 var profile = account.getProfileStore().getProfile(recipientId);
236
237 if (!force && !isProfileRefreshRequired(profile)) {
238 return profile;
239 }
240
241 try {
242 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE));
243 } catch (IOException e) {
244 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
245 }
246
247 return account.getProfileStore().getProfile(recipientId);
248 }
249
250 private boolean isProfileRefreshRequired(final Profile profile) {
251 if (profile == null) {
252 return true;
253 }
254 // Profiles are cached for 6h before retrieving them again, unless forced
255 final var now = System.currentTimeMillis();
256 return now - profile.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
257 }
258
259 private SignalServiceProfile retrieveProfileSync(String username) throws IOException {
260 final var locale = Utils.getDefaultLocale(Locale.US);
261 return dependencies.getMessageReceiver().retrieveProfileByUsername(username, Optional.empty(), locale);
262 }
263
264 private Profile decryptProfileAndDownloadAvatar(
265 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
266 ) {
267 final var avatarPath = encryptedProfile.getAvatar();
268 downloadProfileAvatar(recipientId, avatarPath, profileKey);
269
270 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
271 }
272
273 public void downloadProfileAvatar(
274 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
275 ) {
276 var profile = account.getProfileStore().getProfile(recipientId);
277 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
278 logger.trace("Downloading profile avatar for {}", recipientId);
279 downloadProfileAvatar(account.getRecipientStore().resolveRecipientAddress(recipientId),
280 avatarPath,
281 profileKey);
282 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
283 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
284 }
285 }
286
287 private ProfileAndCredential blockingGetProfile(Single<ProfileAndCredential> profile) throws IOException {
288 try {
289 return profile.blockingGet();
290 } catch (RuntimeException e) {
291 if (e.getCause() instanceof PushNetworkException) {
292 throw (PushNetworkException) e.getCause();
293 } else if (e.getCause() instanceof NotFoundException) {
294 throw (NotFoundException) e.getCause();
295 } else {
296 throw new IOException(e);
297 }
298 }
299 }
300
301 private Single<ProfileAndCredential> retrieveProfile(
302 RecipientId recipientId, SignalServiceProfile.RequestType requestType
303 ) {
304 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
305 var profileKey = Optional.ofNullable(account.getProfileStore().getProfileKey(recipientId));
306
307 logger.trace("Retrieving profile for {} {}",
308 recipientId,
309 profileKey.isPresent() ? "with profile key" : "without profile key");
310 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
311 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType).doOnSuccess(p -> {
312 logger.trace("Got new profile for {}", recipientId);
313 final var encryptedProfile = p.getProfile();
314
315 if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL
316 || account.getProfileStore().getProfileKeyCredential(recipientId) == null) {
317 logger.trace("Storing profile credential");
318 final var profileKeyCredential = p.getProfileKeyCredential().orElse(null);
319 account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
320 }
321
322 final var profile = account.getProfileStore().getProfile(recipientId);
323
324 Profile newProfile = null;
325 if (profileKey.isPresent()) {
326 logger.trace("Decrypting profile");
327 newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey.get(), encryptedProfile);
328 }
329
330 if (newProfile == null) {
331 newProfile = (
332 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
333 ).withLastUpdateTimestamp(System.currentTimeMillis())
334 .withUnidentifiedAccessMode(ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null))
335 .withCapabilities(ProfileUtils.getCapabilities(encryptedProfile))
336 .build();
337 }
338
339 try {
340 logger.trace("Storing identity");
341 final var identityKey = new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey()));
342 account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date());
343 } catch (InvalidKeyException ignored) {
344 logger.warn("Got invalid identity key in profile for {}",
345 context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier());
346 }
347
348 logger.trace("Storing profile");
349 account.getProfileStore().storeProfile(recipientId, newProfile);
350
351 logger.trace("Done handling retrieved profile");
352 }).doOnError(e -> {
353 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
354 final var profile = account.getProfileStore().getProfile(recipientId);
355 final var newProfile = (
356 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
357 ).withLastUpdateTimestamp(System.currentTimeMillis())
358 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
359 .withCapabilities(Set.of())
360 .build();
361
362 account.getProfileStore().storeProfile(recipientId, newProfile);
363 });
364 }
365
366 private Single<ProfileAndCredential> retrieveProfile(
367 SignalServiceAddress address,
368 Optional<ProfileKey> profileKey,
369 Optional<UnidentifiedAccess> unidentifiedAccess,
370 SignalServiceProfile.RequestType requestType
371 ) {
372 final var profileService = dependencies.getProfileService();
373 final var locale = Utils.getDefaultLocale(Locale.US);
374
375 return profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale).map(pair -> {
376 var processor = new ProfileService.ProfileResponseProcessor(pair);
377 if (processor.hasResult()) {
378 return processor.getResult();
379 } else if (processor.notFound()) {
380 throw new NotFoundException("Profile not found");
381 } else {
382 throw pair.getExecutionError()
383 .or(pair::getApplicationError)
384 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
385 }
386 });
387 }
388
389 private void downloadProfileAvatar(
390 RecipientAddress address, String avatarPath, ProfileKey profileKey
391 ) {
392 if (avatarPath == null) {
393 try {
394 context.getAvatarStore().deleteProfileAvatar(address);
395 } catch (IOException e) {
396 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
397 }
398 return;
399 }
400
401 try {
402 context.getAvatarStore()
403 .storeProfileAvatar(address,
404 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
405 } catch (Throwable e) {
406 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
407 }
408 }
409
410 private void retrieveProfileAvatar(
411 String avatarPath, ProfileKey profileKey, OutputStream outputStream
412 ) throws IOException {
413 var tmpFile = IOUtils.createTempFile();
414 try (var input = dependencies.getMessageReceiver()
415 .retrieveProfileAvatar(avatarPath,
416 tmpFile,
417 profileKey,
418 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
419 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
420 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
421 } finally {
422 try {
423 Files.delete(tmpFile.toPath());
424 } catch (IOException e) {
425 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
426 tmpFile,
427 e.getMessage());
428 }
429 }
430 }
431
432 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
433 var unidentifiedAccess = context.getUnidentifiedAccessHelper().getAccessFor(recipientId, true);
434
435 if (unidentifiedAccess.isPresent()) {
436 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
437 }
438
439 return Optional.empty();
440 }
441 }