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