]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Ignore decryption failures from blocked contacts
[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.storage.SignalAccount;
6 import org.asamk.signal.manager.storage.recipients.Profile;
7 import org.asamk.signal.manager.storage.recipients.RecipientId;
8 import org.asamk.signal.manager.util.IOUtils;
9 import org.asamk.signal.manager.util.ProfileUtils;
10 import org.asamk.signal.manager.util.Utils;
11 import org.signal.zkgroup.profiles.ProfileKey;
12 import org.signal.zkgroup.profiles.ProfileKeyCredential;
13 import org.slf4j.Logger;
14 import org.slf4j.LoggerFactory;
15 import org.whispersystems.libsignal.IdentityKey;
16 import org.whispersystems.libsignal.InvalidKeyException;
17 import org.whispersystems.libsignal.util.guava.Optional;
18 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
19 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
20 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
21 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
22 import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
23 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
24 import org.whispersystems.signalservice.api.services.ProfileService;
25
26 import java.io.File;
27 import java.io.IOException;
28 import java.io.OutputStream;
29 import java.nio.file.Files;
30 import java.util.Base64;
31 import java.util.Date;
32 import java.util.List;
33 import java.util.Objects;
34 import java.util.Set;
35
36 import io.reactivex.rxjava3.core.Maybe;
37 import io.reactivex.rxjava3.core.Single;
38
39 public final class ProfileHelper {
40
41 private final static Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
42
43 private final SignalAccount account;
44 private final SignalDependencies dependencies;
45 private final Context context;
46
47 public ProfileHelper(final Context context) {
48 this.account = context.getAccount();
49 this.dependencies = context.getDependencies();
50 this.context = context;
51 }
52
53 public Profile getRecipientProfile(RecipientId recipientId) {
54 return getRecipientProfile(recipientId, false);
55 }
56
57 public void refreshRecipientProfile(RecipientId recipientId) {
58 getRecipientProfile(recipientId, true);
59 }
60
61 public List<ProfileKeyCredential> getRecipientProfileKeyCredential(List<RecipientId> recipientIds) {
62 final var profileFetches = recipientIds.stream().map(recipientId -> {
63 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
64 if (profileKeyCredential != null) {
65 return null;
66 }
67
68 return retrieveProfile(recipientId,
69 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete();
70 }).filter(Objects::nonNull).toList();
71 Maybe.merge(profileFetches).blockingSubscribe();
72
73 return recipientIds.stream().map(r -> account.getProfileStore().getProfileKeyCredential(r)).toList();
74 }
75
76 public ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
77 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
78 if (profileKeyCredential != null) {
79 return profileKeyCredential;
80 }
81
82 try {
83 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL));
84 } catch (IOException e) {
85 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
86 return null;
87 }
88
89 return account.getProfileStore().getProfileKeyCredential(recipientId);
90 }
91
92 /**
93 * @param givenName if null, the previous givenName will be kept
94 * @param familyName if null, the previous familyName will be kept
95 * @param about if null, the previous about text will be kept
96 * @param aboutEmoji if null, the previous about emoji will be kept
97 * @param avatar if avatar is null the image from the local avatar store is used (if present),
98 */
99 public void setProfile(
100 String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
101 ) throws IOException {
102 setProfile(true, givenName, familyName, about, aboutEmoji, avatar);
103 }
104
105 public void setProfile(
106 boolean uploadProfile,
107 String givenName,
108 final String familyName,
109 String about,
110 String aboutEmoji,
111 Optional<File> avatar
112 ) throws IOException {
113 var profile = getRecipientProfile(account.getSelfRecipientId());
114 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
115 if (givenName != null) {
116 builder.withGivenName(givenName);
117 }
118 if (familyName != null) {
119 builder.withFamilyName(familyName);
120 }
121 if (about != null) {
122 builder.withAbout(about);
123 }
124 if (aboutEmoji != null) {
125 builder.withAboutEmoji(aboutEmoji);
126 }
127 var newProfile = builder.build();
128
129 if (uploadProfile) {
130 try (final var streamDetails = avatar == null
131 ? context.getAvatarStore()
132 .retrieveProfileAvatar(account.getSelfAddress())
133 : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) {
134 final var avatarPath = dependencies.getAccountManager()
135 .setVersionedProfile(account.getAci(),
136 account.getProfileKey(),
137 newProfile.getInternalServiceName(),
138 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
139 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
140 Optional.absent(),
141 streamDetails,
142 List.of(/* TODO */));
143 builder.withAvatarUrlPath(avatarPath.orNull());
144 newProfile = builder.build();
145 }
146 }
147
148 if (avatar != null) {
149 if (avatar.isPresent()) {
150 context.getAvatarStore()
151 .storeProfileAvatar(account.getSelfAddress(),
152 outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
153 } else {
154 context.getAvatarStore().deleteProfileAvatar(account.getSelfAddress());
155 }
156 }
157 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
158 }
159
160 public List<Profile> getRecipientProfile(List<RecipientId> recipientIds) {
161 final var profileFetches = recipientIds.stream().map(recipientId -> {
162 var profile = account.getProfileStore().getProfile(recipientId);
163 if (!isProfileRefreshRequired(profile)) {
164 return null;
165 }
166
167 return retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE).onErrorComplete();
168 }).filter(Objects::nonNull).toList();
169 Maybe.merge(profileFetches).blockingSubscribe();
170
171 return recipientIds.stream().map(r -> account.getProfileStore().getProfile(r)).toList();
172 }
173
174 private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
175 var profile = account.getProfileStore().getProfile(recipientId);
176
177 if (!force && !isProfileRefreshRequired(profile)) {
178 return profile;
179 }
180
181 try {
182 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE));
183 } catch (IOException e) {
184 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
185 }
186
187 return account.getProfileStore().getProfile(recipientId);
188 }
189
190 private boolean isProfileRefreshRequired(final Profile profile) {
191 if (profile == null) {
192 return true;
193 }
194 // Profiles are cached for 6h before retrieving them again, unless forced
195 final var now = System.currentTimeMillis();
196 return now - profile.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
197 }
198
199 private SignalServiceProfile retrieveProfileSync(String username) throws IOException {
200 final var locale = Utils.getDefaultLocale();
201 return dependencies.getMessageReceiver().retrieveProfileByUsername(username, Optional.absent(), locale);
202 }
203
204 private Profile decryptProfileAndDownloadAvatar(
205 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
206 ) {
207 final var avatarPath = encryptedProfile.getAvatar();
208 downloadProfileAvatar(recipientId, avatarPath, profileKey);
209
210 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
211 }
212
213 public void downloadProfileAvatar(
214 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
215 ) {
216 var profile = account.getProfileStore().getProfile(recipientId);
217 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
218 downloadProfileAvatar(context.getRecipientHelper().resolveSignalServiceAddress(recipientId),
219 avatarPath,
220 profileKey);
221 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
222 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
223 }
224 }
225
226 private ProfileAndCredential blockingGetProfile(Single<ProfileAndCredential> profile) throws IOException {
227 try {
228 return profile.blockingGet();
229 } catch (RuntimeException e) {
230 if (e.getCause() instanceof PushNetworkException) {
231 throw (PushNetworkException) e.getCause();
232 } else if (e.getCause() instanceof NotFoundException) {
233 throw (NotFoundException) e.getCause();
234 } else {
235 throw new IOException(e);
236 }
237 }
238 }
239
240 private Single<ProfileAndCredential> retrieveProfile(
241 RecipientId recipientId, SignalServiceProfile.RequestType requestType
242 ) {
243 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
244 var profileKey = Optional.fromNullable(account.getProfileStore().getProfileKey(recipientId));
245
246 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
247 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType).doOnSuccess(p -> {
248 final var encryptedProfile = p.getProfile();
249
250 if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
251 final var profileKeyCredential = p.getProfileKeyCredential().orNull();
252 account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
253 }
254
255 final var profile = account.getProfileStore().getProfile(recipientId);
256
257 Profile newProfile = null;
258 if (profileKey.isPresent()) {
259 newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey.get(), encryptedProfile);
260 }
261
262 if (newProfile == null) {
263 newProfile = (
264 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
265 ).withLastUpdateTimestamp(System.currentTimeMillis())
266 .withUnidentifiedAccessMode(ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null))
267 .withCapabilities(ProfileUtils.getCapabilities(encryptedProfile))
268 .build();
269 }
270
271 account.getProfileStore().storeProfile(recipientId, newProfile);
272
273 try {
274 var newIdentity = account.getIdentityKeyStore()
275 .saveIdentity(recipientId,
276 new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey())),
277 new Date());
278
279 if (newIdentity) {
280 account.getSessionStore().archiveSessions(recipientId);
281 account.getSenderKeyStore().deleteSharedWith(recipientId);
282 }
283 } catch (InvalidKeyException ignored) {
284 logger.warn("Got invalid identity key in profile for {}",
285 context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier());
286 }
287 }).doOnError(e -> {
288 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
289 final var profile = account.getProfileStore().getProfile(recipientId);
290 final var newProfile = (
291 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
292 ).withLastUpdateTimestamp(System.currentTimeMillis())
293 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
294 .withCapabilities(Set.of())
295 .build();
296
297 account.getProfileStore().storeProfile(recipientId, newProfile);
298 });
299 }
300
301 private Single<ProfileAndCredential> retrieveProfile(
302 SignalServiceAddress address,
303 Optional<ProfileKey> profileKey,
304 Optional<UnidentifiedAccess> unidentifiedAccess,
305 SignalServiceProfile.RequestType requestType
306 ) {
307 final var profileService = dependencies.getProfileService();
308 final var locale = Utils.getDefaultLocale();
309
310 return profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale).map(pair -> {
311 var processor = new ProfileService.ProfileResponseProcessor(pair);
312 if (processor.hasResult()) {
313 return processor.getResult();
314 } else if (processor.notFound()) {
315 throw new NotFoundException("Profile not found");
316 } else {
317 throw pair.getExecutionError()
318 .or(pair.getApplicationError())
319 .or(new IOException("Unknown error while retrieving profile"));
320 }
321 });
322 }
323
324 private void downloadProfileAvatar(
325 SignalServiceAddress address, String avatarPath, ProfileKey profileKey
326 ) {
327 if (avatarPath == null) {
328 try {
329 context.getAvatarStore().deleteProfileAvatar(address);
330 } catch (IOException e) {
331 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
332 }
333 return;
334 }
335
336 try {
337 context.getAvatarStore()
338 .storeProfileAvatar(address,
339 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
340 } catch (Throwable e) {
341 if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
342 Thread.currentThread().interrupt();
343 }
344 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
345 }
346 }
347
348 private void retrieveProfileAvatar(
349 String avatarPath, ProfileKey profileKey, OutputStream outputStream
350 ) throws IOException {
351 var tmpFile = IOUtils.createTempFile();
352 try (var input = dependencies.getMessageReceiver()
353 .retrieveProfileAvatar(avatarPath,
354 tmpFile,
355 profileKey,
356 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
357 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
358 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
359 } finally {
360 try {
361 Files.delete(tmpFile.toPath());
362 } catch (IOException e) {
363 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
364 tmpFile,
365 e.getMessage());
366 }
367 }
368 }
369
370 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
371 var unidentifiedAccess = context.getUnidentifiedAccessHelper().getAccessFor(recipientId, true);
372
373 if (unidentifiedAccess.isPresent()) {
374 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
375 }
376
377 return Optional.absent();
378 }
379 }