]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
8c8ac6fcbb894619e30112596225ecc5101a2083
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / StorageHelper.java
1 package org.asamk.signal.manager.helper;
2
3 import org.asamk.signal.manager.api.GroupIdV1;
4 import org.asamk.signal.manager.api.GroupIdV2;
5 import org.asamk.signal.manager.internal.SignalDependencies;
6 import org.asamk.signal.manager.storage.SignalAccount;
7 import org.asamk.signal.manager.storage.recipients.RecipientId;
8 import org.asamk.signal.manager.syncStorage.AccountRecordProcessor;
9 import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
10 import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
11 import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
12 import org.asamk.signal.manager.syncStorage.StorageSyncModels;
13 import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
14 import org.asamk.signal.manager.syncStorage.WriteOperationResult;
15 import org.asamk.signal.manager.util.KeyUtils;
16 import org.signal.core.util.SetUtil;
17 import org.signal.libsignal.protocol.InvalidKeyException;
18 import org.slf4j.Logger;
19 import org.slf4j.LoggerFactory;
20 import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
21 import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
22 import org.whispersystems.signalservice.api.storage.StorageId;
23 import org.whispersystems.signalservice.api.storage.StorageKey;
24 import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord;
25
26 import java.io.IOException;
27 import java.sql.Connection;
28 import java.sql.SQLException;
29 import java.util.ArrayList;
30 import java.util.Base64;
31 import java.util.Collection;
32 import java.util.Collections;
33 import java.util.List;
34 import java.util.Map;
35 import java.util.Optional;
36 import java.util.stream.Collectors;
37
38 public class StorageHelper {
39
40 private static final Logger logger = LoggerFactory.getLogger(StorageHelper.class);
41 private static final List<Integer> KNOWN_TYPES = List.of(ManifestRecord.Identifier.Type.CONTACT.getValue(),
42 ManifestRecord.Identifier.Type.GROUPV1.getValue(),
43 ManifestRecord.Identifier.Type.GROUPV2.getValue(),
44 ManifestRecord.Identifier.Type.ACCOUNT.getValue());
45
46 private final SignalAccount account;
47 private final SignalDependencies dependencies;
48 private final Context context;
49
50 public StorageHelper(final Context context) {
51 this.account = context.getAccount();
52 this.dependencies = context.getDependencies();
53 this.context = context;
54 }
55
56 public void syncDataWithStorage() throws IOException {
57 final var storageKey = account.getOrCreateStorageKey();
58 if (storageKey == null) {
59 if (!account.isPrimaryDevice()) {
60 logger.debug("Storage key unknown, requesting from primary device.");
61 context.getSyncHelper().requestSyncKeys();
62 }
63 return;
64 }
65
66 logger.trace("Reading manifest from remote storage");
67 final var localManifestVersion = account.getStorageManifestVersion();
68 final var localManifest = account.getStorageManifest().orElse(SignalStorageManifest.EMPTY);
69 SignalStorageManifest remoteManifest;
70 try {
71 remoteManifest = dependencies.getAccountManager()
72 .getStorageManifestIfDifferentVersion(storageKey, localManifestVersion)
73 .orElse(localManifest);
74 } catch (InvalidKeyException e) {
75 logger.warn("Manifest couldn't be decrypted.");
76 if (account.isPrimaryDevice()) {
77 try {
78 forcePushToStorage(storageKey);
79 } catch (RetryLaterException rle) {
80 // TODO retry later
81 return;
82 }
83 }
84 return;
85 }
86
87 logger.trace("Manifest versions: local {}, remote {}", localManifestVersion, remoteManifest.getVersion());
88
89 var needsForcePush = false;
90 if (remoteManifest.getVersion() > localManifestVersion) {
91 logger.trace("Remote version was newer, reading records.");
92 needsForcePush = readDataFromStorage(storageKey, localManifest, remoteManifest);
93 } else if (remoteManifest.getVersion() < localManifest.getVersion()) {
94 logger.debug("Remote storage manifest version was older. User might have switched accounts.");
95 }
96 logger.trace("Done reading data from remote storage");
97
98 if (localManifest != remoteManifest) {
99 storeManifestLocally(remoteManifest);
100 }
101
102 readRecordsWithPreviouslyUnknownTypes(storageKey);
103
104 logger.trace("Adding missing storageIds to local data");
105 account.getRecipientStore().setMissingStorageIds();
106 account.getGroupStore().setMissingStorageIds();
107
108 var needsMultiDeviceSync = false;
109 try {
110 needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush);
111 } catch (RetryLaterException e) {
112 // TODO retry later
113 return;
114 }
115
116 if (needsForcePush) {
117 logger.debug("Doing a force push.");
118 try {
119 forcePushToStorage(storageKey);
120 needsMultiDeviceSync = true;
121 } catch (RetryLaterException e) {
122 // TODO retry later
123 return;
124 }
125 }
126
127 if (needsMultiDeviceSync) {
128 context.getSyncHelper().sendSyncFetchStorageMessage();
129 }
130
131 logger.debug("Done syncing data with remote storage");
132 }
133
134 private boolean readDataFromStorage(
135 final StorageKey storageKey,
136 final SignalStorageManifest localManifest,
137 final SignalStorageManifest remoteManifest
138 ) throws IOException {
139 var needsForcePush = false;
140 try (final var connection = account.getAccountDatabase().getConnection()) {
141 connection.setAutoCommit(false);
142
143 var idDifference = findIdDifference(remoteManifest.getStorageIds(), localManifest.getStorageIds());
144
145 if (idDifference.hasTypeMismatches() && account.isPrimaryDevice()) {
146 logger.debug("Found type mismatches in the ID sets! Scheduling a force push after this sync completes.");
147 needsForcePush = true;
148 }
149
150 logger.debug("Pre-Merge ID Difference :: " + idDifference);
151
152 if (!idDifference.isEmpty()) {
153 final var remoteOnlyRecords = getSignalStorageRecords(storageKey, idDifference.remoteOnlyIds());
154
155 if (remoteOnlyRecords.size() != idDifference.remoteOnlyIds().size()) {
156 logger.debug("Could not find all remote-only records! Requested: "
157 + idDifference.remoteOnlyIds()
158 .size()
159 + ", Found: "
160 + remoteOnlyRecords.size()
161 + ". These stragglers should naturally get deleted during the sync.");
162 }
163
164 final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
165 final var unknownDeletes = idDifference.localOnlyIds()
166 .stream()
167 .filter(id -> !KNOWN_TYPES.contains(id.getType()))
168 .toList();
169
170 logger.debug("Storage ids with unknown type: {} inserts, {} deletes",
171 unknownInserts.size(),
172 unknownDeletes.size());
173
174 account.getUnknownStorageIdStore().addUnknownStorageIds(connection, unknownInserts);
175 account.getUnknownStorageIdStore().deleteUnknownStorageIds(connection, unknownDeletes);
176 } else {
177 logger.debug("Remote version was newer, but there were no remote-only IDs.");
178 }
179 connection.commit();
180 } catch (SQLException e) {
181 throw new RuntimeException("Failed to sync remote storage", e);
182 }
183 return needsForcePush;
184 }
185
186 private void readRecordsWithPreviouslyUnknownTypes(final StorageKey storageKey) throws IOException {
187 try (final var connection = account.getAccountDatabase().getConnection()) {
188 connection.setAutoCommit(false);
189 final var knownUnknownIds = account.getUnknownStorageIdStore()
190 .getUnknownStorageIds(connection, KNOWN_TYPES);
191
192 if (!knownUnknownIds.isEmpty()) {
193 logger.debug("We have " + knownUnknownIds.size() + " unknown records that we can now process.");
194
195 final var remote = getSignalStorageRecords(storageKey, knownUnknownIds);
196
197 logger.debug("Found " + remote.size() + " of the known-unknowns remotely.");
198
199 processKnownRecords(connection, remote);
200 account.getUnknownStorageIdStore()
201 .deleteUnknownStorageIds(connection, remote.stream().map(SignalStorageRecord::getId).toList());
202 }
203 connection.commit();
204 } catch (SQLException e) {
205 throw new RuntimeException("Failed to sync remote storage", e);
206 }
207 }
208
209 private boolean writeToStorage(
210 final StorageKey storageKey, final SignalStorageManifest remoteManifest, final boolean needsForcePush
211 ) throws IOException, RetryLaterException {
212 final WriteOperationResult remoteWriteOperation;
213 try (final var connection = account.getAccountDatabase().getConnection()) {
214 connection.setAutoCommit(false);
215
216 final var localStorageIds = getAllLocalStorageIds(connection);
217 final var idDifference = findIdDifference(remoteManifest.getStorageIds(), localStorageIds);
218 logger.debug("ID Difference :: " + idDifference);
219
220 final var remoteDeletes = idDifference.remoteOnlyIds().stream().map(StorageId::getRaw).toList();
221 final var remoteInserts = buildLocalStorageRecords(connection, idDifference.localOnlyIds());
222 // TODO check if local storage record proto matches remote, then reset to remote storage_id
223
224 remoteWriteOperation = new WriteOperationResult(new SignalStorageManifest(remoteManifest.getVersion() + 1,
225 account.getDeviceId(),
226 localStorageIds), remoteInserts, remoteDeletes);
227
228 connection.commit();
229 } catch (SQLException e) {
230 throw new RuntimeException("Failed to sync remote storage", e);
231 }
232
233 if (remoteWriteOperation.isEmpty()) {
234 logger.debug("No remote writes needed. Still at version: " + remoteManifest.getVersion());
235 return false;
236 }
237
238 logger.debug("We have something to write remotely.");
239 logger.debug("WriteOperationResult :: " + remoteWriteOperation);
240
241 StorageSyncValidations.validate(remoteWriteOperation,
242 remoteManifest,
243 needsForcePush,
244 account.getSelfRecipientAddress());
245
246 final Optional<SignalStorageManifest> conflict;
247 try {
248 conflict = dependencies.getAccountManager()
249 .writeStorageRecords(storageKey,
250 remoteWriteOperation.manifest(),
251 remoteWriteOperation.inserts(),
252 remoteWriteOperation.deletes());
253 } catch (InvalidKeyException e) {
254 logger.warn("Failed to decrypt conflicting storage manifest: {}", e.getMessage());
255 throw new IOException(e);
256 }
257
258 if (conflict.isPresent()) {
259 logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
260 throw new RetryLaterException();
261 }
262
263 logger.debug("Saved new manifest. Now at version: " + remoteWriteOperation.manifest().getVersion());
264 storeManifestLocally(remoteWriteOperation.manifest());
265
266 return true;
267 }
268
269 private void forcePushToStorage(
270 final StorageKey storageServiceKey
271 ) throws IOException, RetryLaterException {
272 logger.debug("Force pushing local state to remote storage");
273
274 final var currentVersion = dependencies.getAccountManager().getStorageManifestVersion();
275 final var newVersion = currentVersion + 1;
276 final var newStorageRecords = new ArrayList<SignalStorageRecord>();
277 final Map<RecipientId, StorageId> newContactStorageIds;
278 final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
279 final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
280
281 try (final var connection = account.getAccountDatabase().getConnection()) {
282 connection.setAutoCommit(false);
283
284 final var recipientIds = account.getRecipientStore().getRecipientIds(connection);
285 newContactStorageIds = generateContactStorageIds(recipientIds);
286 for (final var recipientId : recipientIds) {
287 final var storageId = newContactStorageIds.get(recipientId);
288 if (storageId.getType() == ManifestRecord.Identifier.Type.ACCOUNT.getValue()) {
289 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
290 final var accountRecord = StorageSyncModels.localToRemoteRecord(account.getConfigurationStore(),
291 recipient,
292 account.getUsernameLink(),
293 storageId.getRaw());
294 newStorageRecords.add(accountRecord);
295 } else {
296 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
297 final var address = recipient.getAddress().getIdentifier();
298 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, address);
299 final var record = StorageSyncModels.localToRemoteRecord(recipient, identity, storageId.getRaw());
300 newStorageRecords.add(record);
301 }
302 }
303
304 final var groupV1Ids = account.getGroupStore().getGroupV1Ids(connection);
305 newGroupV1StorageIds = generateGroupV1StorageIds(groupV1Ids);
306 for (final var groupId : groupV1Ids) {
307 final var storageId = newGroupV1StorageIds.get(groupId);
308 final var group = account.getGroupStore().getGroup(connection, groupId);
309 final var record = StorageSyncModels.localToRemoteRecord(group, storageId.getRaw());
310 newStorageRecords.add(record);
311 }
312
313 final var groupV2Ids = account.getGroupStore().getGroupV2Ids(connection);
314 newGroupV2StorageIds = generateGroupV2StorageIds(groupV2Ids);
315 for (final var groupId : groupV2Ids) {
316 final var storageId = newGroupV2StorageIds.get(groupId);
317 final var group = account.getGroupStore().getGroup(connection, groupId);
318 final var record = StorageSyncModels.localToRemoteRecord(group, storageId.getRaw());
319 newStorageRecords.add(record);
320 }
321
322 connection.commit();
323 } catch (SQLException e) {
324 throw new RuntimeException("Failed to sync remote storage", e);
325 }
326 final var newStorageIds = newStorageRecords.stream().map(SignalStorageRecord::getId).toList();
327
328 final var manifest = new SignalStorageManifest(newVersion, account.getDeviceId(), newStorageIds);
329
330 StorageSyncValidations.validateForcePush(manifest, newStorageRecords, account.getSelfRecipientAddress());
331
332 final Optional<SignalStorageManifest> conflict;
333 try {
334 if (newVersion > 1) {
335 logger.trace("Force-pushing data. Inserting {} IDs.", newStorageRecords.size());
336 conflict = dependencies.getAccountManager()
337 .resetStorageRecords(storageServiceKey, manifest, newStorageRecords);
338 } else {
339 logger.trace("First version, normal push. Inserting {} IDs.", newStorageRecords.size());
340 conflict = dependencies.getAccountManager()
341 .writeStorageRecords(storageServiceKey, manifest, newStorageRecords, Collections.emptyList());
342 }
343 } catch (InvalidKeyException e) {
344 logger.debug("Hit an invalid key exception, which likely indicates a conflict.", e);
345 throw new RetryLaterException();
346 }
347
348 if (conflict.isPresent()) {
349 logger.debug("Hit a conflict. Trying again.");
350 throw new RetryLaterException();
351 }
352
353 logger.debug("Force push succeeded. Updating local manifest version to: " + manifest.getVersion());
354 storeManifestLocally(manifest);
355
356 try (final var connection = account.getAccountDatabase().getConnection()) {
357 connection.setAutoCommit(false);
358 account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
359 account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
360
361 // delete all unknown storage ids
362 account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
363 connection.commit();
364 } catch (SQLException e) {
365 throw new RuntimeException("Failed to sync remote storage", e);
366 }
367 }
368
369 private Map<RecipientId, StorageId> generateContactStorageIds(List<RecipientId> recipientIds) {
370 final var selfRecipientId = account.getSelfRecipientId();
371 return recipientIds.stream().collect(Collectors.toMap(recipientId -> recipientId, recipientId -> {
372 if (recipientId.equals(selfRecipientId)) {
373 return StorageId.forAccount(KeyUtils.createRawStorageId());
374 } else {
375 return StorageId.forContact(KeyUtils.createRawStorageId());
376 }
377 }));
378 }
379
380 private Map<GroupIdV1, StorageId> generateGroupV1StorageIds(List<GroupIdV1> groupIds) {
381 return groupIds.stream()
382 .collect(Collectors.toMap(recipientId -> recipientId,
383 recipientId -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
384 }
385
386 private Map<GroupIdV2, StorageId> generateGroupV2StorageIds(List<GroupIdV2> groupIds) {
387 return groupIds.stream()
388 .collect(Collectors.toMap(recipientId -> recipientId,
389 recipientId -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
390 }
391
392 private void storeManifestLocally(
393 final SignalStorageManifest remoteManifest
394 ) {
395 account.setStorageManifestVersion(remoteManifest.getVersion());
396 account.setStorageManifest(remoteManifest);
397 }
398
399 private List<SignalStorageRecord> getSignalStorageRecords(
400 final StorageKey storageKey, final List<StorageId> storageIds
401 ) throws IOException {
402 List<SignalStorageRecord> records;
403 try {
404 records = dependencies.getAccountManager().readStorageRecords(storageKey, storageIds);
405 } catch (InvalidKeyException e) {
406 logger.warn("Failed to read storage records, ignoring.");
407 return List.of();
408 }
409 return records;
410 }
411
412 private List<StorageId> getAllLocalStorageIds(final Connection connection) throws SQLException {
413 final var storageIds = new ArrayList<StorageId>();
414 storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection));
415 storageIds.addAll(account.getGroupStore().getStorageIds(connection));
416 storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
417 storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
418 return storageIds;
419 }
420
421 private List<SignalStorageRecord> buildLocalStorageRecords(
422 final Connection connection, final List<StorageId> storageIds
423 ) throws SQLException {
424 final var records = new ArrayList<SignalStorageRecord>();
425 for (final var storageId : storageIds) {
426 final var record = buildLocalStorageRecord(connection, storageId);
427 if (record != null) {
428 records.add(record);
429 }
430 }
431 return records;
432 }
433
434 private SignalStorageRecord buildLocalStorageRecord(
435 Connection connection, StorageId storageId
436 ) throws SQLException {
437 return switch (ManifestRecord.Identifier.Type.fromValue(storageId.getType())) {
438 case ManifestRecord.Identifier.Type.CONTACT -> {
439 final var recipient = account.getRecipientStore().getRecipient(connection, storageId);
440 final var address = recipient.getAddress().getIdentifier();
441 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, address);
442 yield StorageSyncModels.localToRemoteRecord(recipient, identity, storageId.getRaw());
443 }
444 case ManifestRecord.Identifier.Type.GROUPV1 -> {
445 final var groupV1 = account.getGroupStore().getGroupV1(connection, storageId);
446 yield StorageSyncModels.localToRemoteRecord(groupV1, storageId.getRaw());
447 }
448 case ManifestRecord.Identifier.Type.GROUPV2 -> {
449 final var groupV2 = account.getGroupStore().getGroupV2(connection, storageId);
450 yield StorageSyncModels.localToRemoteRecord(groupV2, storageId.getRaw());
451 }
452 case ManifestRecord.Identifier.Type.ACCOUNT -> {
453 final var selfRecipient = account.getRecipientStore()
454 .getRecipient(connection, account.getSelfRecipientId());
455 yield StorageSyncModels.localToRemoteRecord(account.getConfigurationStore(),
456 selfRecipient,
457 account.getUsernameLink(),
458 storageId.getRaw());
459 }
460 case null, default -> throw new AssertionError("Got unknown local storage record type: " + storageId);
461 };
462 }
463
464 /**
465 * Given a list of all the local and remote keys you know about, this will
466 * return a result telling
467 * you which keys are exclusively remote and which are exclusively local.
468 *
469 * @param remoteIds All remote keys available.
470 * @param localIds All local keys available.
471 * @return An object describing which keys are exclusive to the remote data set
472 * and which keys are
473 * exclusive to the local data set.
474 */
475 private static IdDifferenceResult findIdDifference(
476 Collection<StorageId> remoteIds, Collection<StorageId> localIds
477 ) {
478 final var base64Encoder = Base64.getEncoder();
479 final var remoteByRawId = remoteIds.stream()
480 .collect(Collectors.toMap(id -> base64Encoder.encodeToString(id.getRaw()), id -> id));
481 final var localByRawId = localIds.stream()
482 .collect(Collectors.toMap(id -> base64Encoder.encodeToString(id.getRaw()), id -> id));
483
484 boolean hasTypeMismatch = remoteByRawId.size() != remoteIds.size() || localByRawId.size() != localIds.size();
485
486 final var remoteOnlyRawIds = SetUtil.difference(remoteByRawId.keySet(), localByRawId.keySet());
487 final var localOnlyRawIds = SetUtil.difference(localByRawId.keySet(), remoteByRawId.keySet());
488 final var sharedRawIds = SetUtil.intersection(localByRawId.keySet(), remoteByRawId.keySet());
489
490 for (String rawId : sharedRawIds) {
491 final var remote = remoteByRawId.get(rawId);
492 final var local = localByRawId.get(rawId);
493
494 if (remote.getType() != local.getType()) {
495 remoteOnlyRawIds.remove(rawId);
496 localOnlyRawIds.remove(rawId);
497 hasTypeMismatch = true;
498 logger.debug("Remote type {} did not match local type {} for {}!",
499 remote.getType(),
500 local.getType(),
501 rawId);
502 }
503 }
504
505 final var remoteOnlyKeys = remoteOnlyRawIds.stream().map(remoteByRawId::get).toList();
506 final var localOnlyKeys = localOnlyRawIds.stream().map(localByRawId::get).toList();
507
508 return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
509 }
510
511 private List<StorageId> processKnownRecords(
512 final Connection connection, List<SignalStorageRecord> records
513 ) throws SQLException {
514 final var unknownRecords = new ArrayList<StorageId>();
515
516 final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor());
517 final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
518 final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
519 final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
520
521 for (final var record : records) {
522 logger.debug("Reading record of type {}", record.getType());
523 switch (ManifestRecord.Identifier.Type.fromValue(record.getType())) {
524 case ACCOUNT -> accountRecordProcessor.process(record.getAccount().get());
525 case GROUPV1 -> groupV1RecordProcessor.process(record.getGroupV1().get());
526 case GROUPV2 -> groupV2RecordProcessor.process(record.getGroupV2().get());
527 case CONTACT -> contactRecordProcessor.process(record.getContact().get());
528 case null, default -> unknownRecords.add(record.getId());
529 }
530 }
531
532 return unknownRecords;
533 }
534
535 /**
536 * hasTypeMismatches is True if there exist some keys that have matching raw ID's but different types, otherwise false.
537 */
538 private record IdDifferenceResult(
539 List<StorageId> remoteOnlyIds, List<StorageId> localOnlyIds, boolean hasTypeMismatches
540 ) {
541
542 public boolean isEmpty() {
543 return remoteOnlyIds.isEmpty() && localOnlyIds.isEmpty();
544 }
545 }
546
547 private static class RetryLaterException extends Throwable {}
548 }