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