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