]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Update libsignal-service
[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.api.GroupNotFoundException;
4 import org.asamk.signal.manager.api.NotAGroupMemberException;
5 import org.asamk.signal.manager.api.PhoneNumberSharingMode;
6 import org.asamk.signal.manager.api.Profile;
7 import org.asamk.signal.manager.config.ServiceConfig;
8 import org.asamk.signal.manager.internal.SignalDependencies;
9 import org.asamk.signal.manager.jobs.SyncStorageJob;
10 import org.asamk.signal.manager.storage.SignalAccount;
11 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
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.PaymentUtils;
17 import org.asamk.signal.manager.util.ProfileUtils;
18 import org.asamk.signal.manager.util.Utils;
19 import org.jetbrains.annotations.Nullable;
20 import org.signal.libsignal.protocol.IdentityKey;
21 import org.signal.libsignal.protocol.InvalidKeyException;
22 import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
23 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26 import org.whispersystems.signalservice.api.NetworkResultUtil;
27 import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
28 import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
29 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
30 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
31 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
32 import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
33 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
34 import org.whispersystems.signalservice.api.services.ProfileService;
35 import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
36
37 import java.io.IOException;
38 import java.io.OutputStream;
39 import java.nio.file.Files;
40 import java.util.Base64;
41 import java.util.Collection;
42 import java.util.List;
43 import java.util.Locale;
44 import java.util.Objects;
45 import java.util.Optional;
46 import java.util.Set;
47
48 import io.reactivex.rxjava3.core.Flowable;
49 import io.reactivex.rxjava3.core.Maybe;
50 import io.reactivex.rxjava3.core.Single;
51
52 public final class ProfileHelper {
53
54 private static final Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
55
56 private final SignalAccount account;
57 private final SignalDependencies dependencies;
58 private final Context context;
59
60 public ProfileHelper(final Context context) {
61 this.account = context.getAccount();
62 this.dependencies = context.getDependencies();
63 this.context = context;
64 }
65
66 public void rotateProfileKey() throws IOException {
67 // refresh our profile, before creating a new profile key
68 getSelfProfile();
69 var profileKey = KeyUtils.createProfileKey();
70 account.setProfileKey(profileKey);
71 context.getAccountHelper().updateAccountAttributes();
72 setProfile(true, true, null, null, null, null, null, null);
73 account.getRecipientStore().rotateSelfStorageId();
74 context.getJobExecutor().enqueueJob(new SyncStorageJob());
75
76 final var recipientIds = account.getRecipientStore().getRecipientIdsWithEnabledProfileSharing();
77 for (final var recipientId : recipientIds) {
78 context.getSendHelper().sendProfileKey(recipientId);
79 }
80
81 final var selfRecipientId = account.getSelfRecipientId();
82 final var activeGroupIds = account.getGroupStore()
83 .getGroups()
84 .stream()
85 .filter(g -> g instanceof GroupInfoV2 && g.isMember(selfRecipientId) && g.isProfileSharingEnabled())
86 .map(g -> (GroupInfoV2) g)
87 .map(GroupInfoV2::getGroupId)
88 .toList();
89 for (final var groupId : activeGroupIds) {
90 try {
91 context.getGroupHelper().updateGroupProfileKey(groupId);
92 } catch (GroupNotFoundException | NotAGroupMemberException | IOException e) {
93 logger.warn("Failed to update group profile key: {}", e.getMessage());
94 }
95 }
96 }
97
98 public Profile getRecipientProfile(RecipientId recipientId) {
99 return getRecipientProfile(recipientId, false);
100 }
101
102 public List<Profile> getRecipientProfiles(Collection<RecipientId> recipientIds) {
103 return getRecipientProfiles(recipientIds, false);
104 }
105
106 public void refreshRecipientProfile(RecipientId recipientId) {
107 getRecipientProfile(recipientId, true);
108 }
109
110 public void refreshRecipientProfiles(Collection<RecipientId> recipientIds) {
111 getRecipientProfiles(recipientIds, true);
112 }
113
114 public List<ExpiringProfileKeyCredential> getExpiringProfileKeyCredential(List<RecipientId> recipientIds) {
115 final var profileFetches = Flowable.fromIterable(recipientIds)
116 .filter(recipientId -> !ExpiringProfileCredentialUtil.isValid(account.getProfileStore()
117 .getExpiringProfileKeyCredential(recipientId)))
118 .map(recipientId -> retrieveProfile(recipientId,
119 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete());
120 Maybe.merge(profileFetches, 10).blockingSubscribe();
121
122 return recipientIds.stream().map(r -> account.getProfileStore().getExpiringProfileKeyCredential(r)).toList();
123 }
124
125 public ExpiringProfileKeyCredential getExpiringProfileKeyCredential(RecipientId recipientId) {
126 var profileKeyCredential = account.getProfileStore().getExpiringProfileKeyCredential(recipientId);
127 if (ExpiringProfileCredentialUtil.isValid(profileKeyCredential)) {
128 return profileKeyCredential;
129 }
130
131 try {
132 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL));
133 } catch (IOException e) {
134 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
135 return null;
136 }
137
138 return account.getProfileStore().getExpiringProfileKeyCredential(recipientId);
139 }
140
141 /**
142 * @param givenName if null, the previous givenName will be kept
143 * @param familyName if null, the previous familyName will be kept
144 * @param about if null, the previous about text will be kept
145 * @param aboutEmoji if null, the previous about emoji will be kept
146 * @param avatar if avatar is null the image from the local avatar store is used (if present),
147 */
148 public void setProfile(
149 String givenName,
150 final String familyName,
151 String about,
152 String aboutEmoji,
153 Optional<String> avatar,
154 byte[] mobileCoinAddress
155 ) throws IOException {
156 setProfile(true, false, givenName, familyName, about, aboutEmoji, avatar, mobileCoinAddress);
157 }
158
159 public void setProfile(
160 boolean uploadProfile,
161 boolean forceUploadAvatar,
162 String givenName,
163 final String familyName,
164 String about,
165 String aboutEmoji,
166 Optional<String> avatar,
167 byte[] mobileCoinAddress
168 ) throws IOException {
169 var profile = getSelfProfile();
170 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
171 if (givenName != null) {
172 builder.withGivenName(givenName);
173 }
174 if (familyName != null) {
175 builder.withFamilyName(familyName);
176 }
177 if (about != null) {
178 builder.withAbout(about);
179 }
180 if (aboutEmoji != null) {
181 builder.withAboutEmoji(aboutEmoji);
182 }
183 if (mobileCoinAddress != null) {
184 builder.withMobileCoinAddress(mobileCoinAddress);
185 }
186 var newProfile = builder.build();
187
188 if (uploadProfile) {
189 final var streamDetails = avatar != null && avatar.isPresent()
190 ? Utils.createStreamDetails(avatar.get())
191 .first()
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 .orElse(null);
202 logger.debug("Uploading new profile");
203 final var avatarPath = NetworkResultUtil.toSetProfileLegacy(dependencies.getProfileApi()
204 .setVersionedProfile(account.getAci(),
205 account.getProfileKey(),
206 newProfile.getInternalServiceName(),
207 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
208 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
209 paymentsAddress,
210 avatarUploadParams,
211 List.of(/* TODO implement support for badges */),
212 account.getConfigurationStore().getPhoneNumberSharingMode()
213 == PhoneNumberSharingMode.EVERYBODY));
214 if (!avatarUploadParams.keepTheSame) {
215 builder.withAvatarUrlPath(avatarPath);
216 }
217 newProfile = builder.build();
218 }
219 }
220
221 if (avatar != null) {
222 if (avatar.isPresent()) {
223 try (final var streamDetails = Utils.createStreamDetails(avatar.get()).first()) {
224 context.getAvatarStore()
225 .storeProfileAvatar(account.getSelfRecipientAddress(),
226 outputStream -> IOUtils.copyStream(streamDetails.getStream(), outputStream));
227 }
228 } else {
229 context.getAvatarStore().deleteProfileAvatar(account.getSelfRecipientAddress());
230 }
231 }
232 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
233 }
234
235 public Profile getSelfProfile() {
236 return getRecipientProfile(account.getSelfRecipientId());
237 }
238
239 private List<Profile> getRecipientProfiles(Collection<RecipientId> recipientIds, boolean force) {
240 final var profileStore = account.getProfileStore();
241 final var profileFetches = Flowable.fromIterable(recipientIds)
242 .filter(recipientId -> force || isProfileRefreshRequired(profileStore.getProfile(recipientId)))
243 .map(recipientId -> retrieveProfile(recipientId,
244 SignalServiceProfile.RequestType.PROFILE).onErrorComplete());
245 Maybe.merge(profileFetches, 10).blockingSubscribe();
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 Profile decryptProfileAndDownloadAvatar(
276 final RecipientId recipientId,
277 final ProfileKey profileKey,
278 final SignalServiceProfile encryptedProfile
279 ) {
280 final var avatarPath = encryptedProfile.getAvatar();
281 downloadProfileAvatar(recipientId, avatarPath, profileKey);
282
283 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
284 }
285
286 public void downloadProfileAvatar(
287 final RecipientId recipientId,
288 final String avatarPath,
289 final ProfileKey profileKey
290 ) {
291 var profile = account.getProfileStore().getProfile(recipientId);
292 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
293 logger.trace("Downloading profile avatar for {}", recipientId);
294 downloadProfileAvatar(account.getRecipientAddressResolver().resolveRecipientAddress(recipientId),
295 avatarPath,
296 profileKey);
297 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
298 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
299 }
300 }
301
302 private ProfileAndCredential blockingGetProfile(Single<ProfileAndCredential> profile) throws IOException {
303 try {
304 return profile.blockingGet();
305 } catch (RuntimeException e) {
306 if (e.getCause() instanceof PushNetworkException) {
307 throw (PushNetworkException) e.getCause();
308 } else if (e.getCause() instanceof NotFoundException) {
309 throw (NotFoundException) e.getCause();
310 } else {
311 throw new IOException(e);
312 }
313 }
314 }
315
316 private Single<ProfileAndCredential> retrieveProfile(
317 RecipientId recipientId,
318 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 if (recipientId.equals(account.getSelfRecipientId())) {
357 final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess();
358 if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
359 account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
360 }
361 if (account.isPrimaryDevice() && profile != null && newProfile.getCapabilities()
362 .contains(Profile.Capability.storageServiceEncryptionV2Capability) && !profile.getCapabilities()
363 .contains(Profile.Capability.storageServiceEncryptionV2Capability)) {
364 context.getJobExecutor().enqueueJob(new SyncStorageJob(true));
365 }
366 }
367
368 try {
369 logger.trace("Storing identity");
370 final var identityKey = new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey()));
371 account.getIdentityKeyStore().saveIdentity(p.getProfile().getServiceId(), identityKey);
372 } catch (InvalidKeyException ignored) {
373 logger.warn("Got invalid identity key in profile for {}",
374 context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier());
375 }
376
377 logger.trace("Storing profile");
378 account.getProfileStore().storeProfile(recipientId, newProfile);
379 account.getRecipientStore().markRegistered(recipientId, true);
380
381 logger.trace("Done handling retrieved profile");
382 }).doOnError(e -> {
383 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
384 final var profile = account.getProfileStore().getProfile(recipientId);
385 final var newProfile = (
386 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
387 ).withLastUpdateTimestamp(System.currentTimeMillis())
388 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
389 .withCapabilities(Set.of())
390 .build();
391 if (e instanceof NotFoundException) {
392 logger.debug("Marking recipient {} as unregistered after 404 profile fetch.", recipientId);
393 account.getRecipientStore().markRegistered(recipientId, false);
394 }
395
396 account.getProfileStore().storeProfile(recipientId, newProfile);
397 });
398 }
399
400 private Single<ProfileAndCredential> retrieveProfile(
401 SignalServiceAddress address,
402 Optional<ProfileKey> profileKey,
403 @Nullable SealedSenderAccess unidentifiedAccess,
404 SignalServiceProfile.RequestType requestType
405 ) {
406 final var profileService = dependencies.getProfileService();
407 final var locale = Utils.getDefaultLocale(Locale.US);
408
409 return profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale).map(pair -> {
410 var processor = new ProfileService.ProfileResponseProcessor(pair);
411 if (processor.hasResult()) {
412 return processor.getResult();
413 } else if (processor.notFound()) {
414 throw new NotFoundException("Profile not found");
415 } else {
416 throw pair.getExecutionError()
417 .or(pair::getApplicationError)
418 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
419 }
420 });
421 }
422
423 private void downloadProfileAvatar(RecipientAddress address, String avatarPath, ProfileKey profileKey) {
424 if (avatarPath == null) {
425 try {
426 context.getAvatarStore().deleteProfileAvatar(address);
427 } catch (IOException e) {
428 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
429 }
430 return;
431 }
432
433 try {
434 context.getAvatarStore()
435 .storeProfileAvatar(address,
436 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
437 } catch (Throwable e) {
438 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
439 }
440 }
441
442 private void retrieveProfileAvatar(
443 String avatarPath,
444 ProfileKey profileKey,
445 OutputStream outputStream
446 ) throws IOException {
447 var tmpFile = IOUtils.createTempFile();
448 try (var input = dependencies.getMessageReceiver()
449 .retrieveProfileAvatar(avatarPath,
450 tmpFile,
451 profileKey,
452 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
453 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
454 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
455 } finally {
456 try {
457 Files.delete(tmpFile.toPath());
458 } catch (IOException e) {
459 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
460 tmpFile,
461 e.getMessage());
462 }
463 }
464 }
465
466 private @Nullable SealedSenderAccess getUnidentifiedAccess(RecipientId recipientId) {
467 return context.getUnidentifiedAccessHelper().getSealedSenderAccessFor(recipientId, true);
468 }
469 }