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