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