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