]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Refactor identity key store
[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.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<ProfileKeyCredential> getRecipientProfileKeyCredential(List<RecipientId> recipientIds) {
110 try {
111 account.getRecipientStore().setBulkUpdating(true);
112 final var profileFetches = Flowable.fromIterable(recipientIds)
113 .filter(recipientId -> account.getProfileStore().getProfileKeyCredential(recipientId) == null)
114 .map(recipientId -> retrieveProfile(recipientId,
115 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete());
116 Maybe.merge(profileFetches, 10).blockingSubscribe();
117 } finally {
118 account.getRecipientStore().setBulkUpdating(false);
119 }
120
121 return recipientIds.stream().map(r -> account.getProfileStore().getProfileKeyCredential(r)).toList();
122 }
123
124 public ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
125 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
126 if (profileKeyCredential != null) {
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().getProfileKeyCredential(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<File> 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<File> 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.createStreamDetailsFromFile(avatar.get())
190 : forceUploadAvatar && avatar == null ? context.getAvatarStore()
191 .retrieveProfileAvatar(account.getSelfRecipientAddress()) : null;
192 try (streamDetails) {
193 final var avatarUploadParams = streamDetails != null
194 ? AvatarUploadParams.forAvatar(streamDetails)
195 : avatar == null ? AvatarUploadParams.unchanged(true) : AvatarUploadParams.unchanged(false);
196 final var paymentsAddress = Optional.ofNullable(newProfile.getMobileCoinAddress())
197 .map(address -> PaymentUtils.signPaymentsAddress(address,
198 account.getAciIdentityKeyPair().getPrivateKey()));
199 logger.debug("Uploading new profile");
200 final var avatarPath = dependencies.getAccountManager()
201 .setVersionedProfile(account.getAci(),
202 account.getProfileKey(),
203 newProfile.getInternalServiceName(),
204 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
205 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
206 paymentsAddress,
207 avatarUploadParams,
208 List.of(/* TODO implement support for badges */));
209 if (!avatarUploadParams.keepTheSame) {
210 builder.withAvatarUrlPath(avatarPath.orElse(null));
211 }
212 newProfile = builder.build();
213 }
214 }
215
216 if (avatar != null) {
217 if (avatar.isPresent()) {
218 context.getAvatarStore()
219 .storeProfileAvatar(account.getSelfRecipientAddress(),
220 outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
221 } else {
222 context.getAvatarStore().deleteProfileAvatar(account.getSelfRecipientAddress());
223 }
224 }
225 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
226 }
227
228 public Profile getSelfProfile() {
229 return getRecipientProfile(account.getSelfRecipientId());
230 }
231
232 private List<Profile> getRecipientProfiles(Collection<RecipientId> recipientIds, boolean force) {
233 final var profileStore = account.getProfileStore();
234 try {
235 account.getRecipientStore().setBulkUpdating(true);
236 final var profileFetches = Flowable.fromIterable(recipientIds)
237 .filter(recipientId -> force || isProfileRefreshRequired(profileStore.getProfile(recipientId)))
238 .map(recipientId -> retrieveProfile(recipientId,
239 SignalServiceProfile.RequestType.PROFILE).onErrorComplete());
240 Maybe.merge(profileFetches, 10).blockingSubscribe();
241 } finally {
242 account.getRecipientStore().setBulkUpdating(false);
243 }
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 SignalServiceProfile retrieveProfileSync(String username) throws IOException {
274 final var locale = Utils.getDefaultLocale(Locale.US);
275 return dependencies.getMessageReceiver().retrieveProfileByUsername(username, Optional.empty(), locale);
276 }
277
278 private Profile decryptProfileAndDownloadAvatar(
279 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
280 ) {
281 final var avatarPath = encryptedProfile.getAvatar();
282 downloadProfileAvatar(recipientId, avatarPath, profileKey);
283
284 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
285 }
286
287 public void downloadProfileAvatar(
288 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
289 ) {
290 var profile = account.getProfileStore().getProfile(recipientId);
291 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
292 logger.trace("Downloading profile avatar for {}", recipientId);
293 downloadProfileAvatar(account.getRecipientAddressResolver().resolveRecipientAddress(recipientId),
294 avatarPath,
295 profileKey);
296 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
297 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
298 }
299 }
300
301 private ProfileAndCredential blockingGetProfile(Single<ProfileAndCredential> profile) throws IOException {
302 try {
303 return profile.blockingGet();
304 } catch (RuntimeException e) {
305 if (e.getCause() instanceof PushNetworkException) {
306 throw (PushNetworkException) e.getCause();
307 } else if (e.getCause() instanceof NotFoundException) {
308 throw (NotFoundException) e.getCause();
309 } else {
310 throw new IOException(e);
311 }
312 }
313 }
314
315 private Single<ProfileAndCredential> retrieveProfile(
316 RecipientId recipientId, SignalServiceProfile.RequestType requestType
317 ) {
318 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
319 var profileKey = Optional.ofNullable(account.getProfileStore().getProfileKey(recipientId));
320
321 logger.trace("Retrieving profile for {} {}",
322 recipientId,
323 profileKey.isPresent() ? "with profile key" : "without profile key");
324 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
325 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType).doOnSuccess(p -> {
326 logger.trace("Got new profile for {}", recipientId);
327 final var encryptedProfile = p.getProfile();
328
329 if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL
330 || account.getProfileStore().getProfileKeyCredential(recipientId) == null) {
331 logger.trace("Storing profile credential");
332 final var profileKeyCredential = p.getProfileKeyCredential().orElse(null);
333 account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
334 }
335
336 final var profile = account.getProfileStore().getProfile(recipientId);
337
338 Profile newProfile = null;
339 if (profileKey.isPresent()) {
340 logger.trace("Decrypting profile");
341 newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey.get(), encryptedProfile);
342 }
343
344 if (newProfile == null) {
345 newProfile = (
346 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
347 ).withLastUpdateTimestamp(System.currentTimeMillis())
348 .withUnidentifiedAccessMode(ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null))
349 .withCapabilities(ProfileUtils.getCapabilities(encryptedProfile))
350 .build();
351 }
352
353 try {
354 logger.trace("Storing identity");
355 final var identityKey = new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey()));
356 account.getIdentityKeyStore().saveIdentity(recipientId, identityKey);
357 } catch (InvalidKeyException ignored) {
358 logger.warn("Got invalid identity key in profile for {}",
359 context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier());
360 }
361
362 logger.trace("Storing profile");
363 account.getProfileStore().storeProfile(recipientId, newProfile);
364
365 logger.trace("Done handling retrieved profile");
366 }).doOnError(e -> {
367 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
368 final var profile = account.getProfileStore().getProfile(recipientId);
369 final var newProfile = (
370 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
371 ).withLastUpdateTimestamp(System.currentTimeMillis())
372 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
373 .withCapabilities(Set.of())
374 .build();
375
376 account.getProfileStore().storeProfile(recipientId, newProfile);
377 });
378 }
379
380 private Single<ProfileAndCredential> retrieveProfile(
381 SignalServiceAddress address,
382 Optional<ProfileKey> profileKey,
383 Optional<UnidentifiedAccess> unidentifiedAccess,
384 SignalServiceProfile.RequestType requestType
385 ) {
386 final var profileService = dependencies.getProfileService();
387 final var locale = Utils.getDefaultLocale(Locale.US);
388
389 return profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale).map(pair -> {
390 var processor = new ProfileService.ProfileResponseProcessor(pair);
391 if (processor.hasResult()) {
392 return processor.getResult();
393 } else if (processor.notFound()) {
394 throw new NotFoundException("Profile not found");
395 } else {
396 throw pair.getExecutionError()
397 .or(pair::getApplicationError)
398 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
399 }
400 });
401 }
402
403 private void downloadProfileAvatar(
404 RecipientAddress address, String avatarPath, ProfileKey profileKey
405 ) {
406 if (avatarPath == null) {
407 try {
408 context.getAvatarStore().deleteProfileAvatar(address);
409 } catch (IOException e) {
410 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
411 }
412 return;
413 }
414
415 try {
416 context.getAvatarStore()
417 .storeProfileAvatar(address,
418 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
419 } catch (Throwable e) {
420 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
421 }
422 }
423
424 private void retrieveProfileAvatar(
425 String avatarPath, ProfileKey profileKey, OutputStream outputStream
426 ) throws IOException {
427 var tmpFile = IOUtils.createTempFile();
428 try (var input = dependencies.getMessageReceiver()
429 .retrieveProfileAvatar(avatarPath,
430 tmpFile,
431 profileKey,
432 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
433 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
434 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
435 } finally {
436 try {
437 Files.delete(tmpFile.toPath());
438 } catch (IOException e) {
439 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
440 tmpFile,
441 e.getMessage());
442 }
443 }
444 }
445
446 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
447 var unidentifiedAccess = context.getUnidentifiedAccessHelper().getAccessFor(recipientId, true);
448
449 if (unidentifiedAccess.isPresent()) {
450 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
451 }
452
453 return Optional.empty();
454 }
455 }