]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
c18e8df323513aed5d05372b4336d23c3ca76fe8
[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.localOnlyIds().isEmpty()) {
153 final var updated = account.getRecipientStore()
154 .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection, idDifference.localOnlyIds());
155
156 if (updated > 0) {
157 logger.warn(
158 "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
159 updated);
160 }
161 }
162
163 if (!idDifference.isEmpty()) {
164 final var remoteOnlyRecords = getSignalStorageRecords(storageKey, idDifference.remoteOnlyIds());
165
166 if (remoteOnlyRecords.size() != idDifference.remoteOnlyIds().size()) {
167 logger.debug("Could not find all remote-only records! Requested: "
168 + idDifference.remoteOnlyIds()
169 .size()
170 + ", Found: "
171 + remoteOnlyRecords.size()
172 + ". These stragglers should naturally get deleted during the sync.");
173 }
174
175 final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
176 final var unknownDeletes = idDifference.localOnlyIds()
177 .stream()
178 .filter(id -> !KNOWN_TYPES.contains(id.getType()))
179 .toList();
180
181 logger.debug("Storage ids with unknown type: {} inserts, {} deletes",
182 unknownInserts.size(),
183 unknownDeletes.size());
184
185 account.getUnknownStorageIdStore().addUnknownStorageIds(connection, unknownInserts);
186 account.getUnknownStorageIdStore().deleteUnknownStorageIds(connection, unknownDeletes);
187 } else {
188 logger.debug("Remote version was newer, but there were no remote-only IDs.");
189 }
190 connection.commit();
191 } catch (SQLException e) {
192 throw new RuntimeException("Failed to sync remote storage", e);
193 }
194 return needsForcePush;
195 }
196
197 private void readRecordsWithPreviouslyUnknownTypes(final StorageKey storageKey) throws IOException {
198 try (final var connection = account.getAccountDatabase().getConnection()) {
199 connection.setAutoCommit(false);
200 final var knownUnknownIds = account.getUnknownStorageIdStore()
201 .getUnknownStorageIds(connection, KNOWN_TYPES);
202
203 if (!knownUnknownIds.isEmpty()) {
204 logger.debug("We have " + knownUnknownIds.size() + " unknown records that we can now process.");
205
206 final var remote = getSignalStorageRecords(storageKey, knownUnknownIds);
207
208 logger.debug("Found " + remote.size() + " of the known-unknowns remotely.");
209
210 processKnownRecords(connection, remote);
211 account.getUnknownStorageIdStore()
212 .deleteUnknownStorageIds(connection, remote.stream().map(SignalStorageRecord::getId).toList());
213 }
214 connection.commit();
215 } catch (SQLException e) {
216 throw new RuntimeException("Failed to sync remote storage", e);
217 }
218 }
219
220 private boolean writeToStorage(
221 final StorageKey storageKey, final SignalStorageManifest remoteManifest, final boolean needsForcePush
222 ) throws IOException, RetryLaterException {
223 final WriteOperationResult remoteWriteOperation;
224 try (final var connection = account.getAccountDatabase().getConnection()) {
225 connection.setAutoCommit(false);
226
227 final var localStorageIds = getAllLocalStorageIds(connection);
228 final var idDifference = findIdDifference(remoteManifest.getStorageIds(), localStorageIds);
229 logger.debug("ID Difference :: " + idDifference);
230
231 final var remoteDeletes = idDifference.remoteOnlyIds().stream().map(StorageId::getRaw).toList();
232 final var remoteInserts = buildLocalStorageRecords(connection, idDifference.localOnlyIds());
233 // TODO check if local storage record proto matches remote, then reset to remote storage_id
234
235 remoteWriteOperation = new WriteOperationResult(new SignalStorageManifest(remoteManifest.getVersion() + 1,
236 account.getDeviceId(),
237 localStorageIds), remoteInserts, remoteDeletes);
238
239 connection.commit();
240 } catch (SQLException e) {
241 throw new RuntimeException("Failed to sync remote storage", e);
242 }
243
244 if (remoteWriteOperation.isEmpty()) {
245 logger.debug("No remote writes needed. Still at version: " + remoteManifest.getVersion());
246 return false;
247 }
248
249 logger.debug("We have something to write remotely.");
250 logger.debug("WriteOperationResult :: " + remoteWriteOperation);
251
252 StorageSyncValidations.validate(remoteWriteOperation,
253 remoteManifest,
254 needsForcePush,
255 account.getSelfRecipientAddress());
256
257 final Optional<SignalStorageManifest> conflict;
258 try {
259 conflict = dependencies.getAccountManager()
260 .writeStorageRecords(storageKey,
261 remoteWriteOperation.manifest(),
262 remoteWriteOperation.inserts(),
263 remoteWriteOperation.deletes());
264 } catch (InvalidKeyException e) {
265 logger.warn("Failed to decrypt conflicting storage manifest: {}", e.getMessage());
266 throw new IOException(e);
267 }
268
269 if (conflict.isPresent()) {
270 logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
271 throw new RetryLaterException();
272 }
273
274 logger.debug("Saved new manifest. Now at version: " + remoteWriteOperation.manifest().getVersion());
275 storeManifestLocally(remoteWriteOperation.manifest());
276
277 return true;
278 }
279
280 private void forcePushToStorage(
281 final StorageKey storageServiceKey
282 ) throws IOException, RetryLaterException {
283 logger.debug("Force pushing local state to remote storage");
284
285 final var currentVersion = dependencies.getAccountManager().getStorageManifestVersion();
286 final var newVersion = currentVersion + 1;
287 final var newStorageRecords = new ArrayList<SignalStorageRecord>();
288 final Map<RecipientId, StorageId> newContactStorageIds;
289 final Map<GroupIdV1, StorageId> newGroupV1StorageIds;
290 final Map<GroupIdV2, StorageId> newGroupV2StorageIds;
291
292 try (final var connection = account.getAccountDatabase().getConnection()) {
293 connection.setAutoCommit(false);
294
295 final var recipientIds = account.getRecipientStore().getRecipientIds(connection);
296 newContactStorageIds = generateContactStorageIds(recipientIds);
297 for (final var recipientId : recipientIds) {
298 final var storageId = newContactStorageIds.get(recipientId);
299 if (storageId.getType() == ManifestRecord.Identifier.Type.ACCOUNT.getValue()) {
300 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
301 final var accountRecord = StorageSyncModels.localToRemoteRecord(account.getConfigurationStore(),
302 recipient,
303 account.getUsernameLink(),
304 storageId.getRaw());
305 newStorageRecords.add(accountRecord);
306 } else {
307 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
308 final var address = recipient.getAddress().getIdentifier();
309 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, address);
310 final var record = StorageSyncModels.localToRemoteRecord(recipient, identity, storageId.getRaw());
311 newStorageRecords.add(record);
312 }
313 }
314
315 final var groupV1Ids = account.getGroupStore().getGroupV1Ids(connection);
316 newGroupV1StorageIds = generateGroupV1StorageIds(groupV1Ids);
317 for (final var groupId : groupV1Ids) {
318 final var storageId = newGroupV1StorageIds.get(groupId);
319 final var group = account.getGroupStore().getGroup(connection, groupId);
320 final var record = StorageSyncModels.localToRemoteRecord(group, storageId.getRaw());
321 newStorageRecords.add(record);
322 }
323
324 final var groupV2Ids = account.getGroupStore().getGroupV2Ids(connection);
325 newGroupV2StorageIds = generateGroupV2StorageIds(groupV2Ids);
326 for (final var groupId : groupV2Ids) {
327 final var storageId = newGroupV2StorageIds.get(groupId);
328 final var group = account.getGroupStore().getGroup(connection, groupId);
329 final var record = StorageSyncModels.localToRemoteRecord(group, storageId.getRaw());
330 newStorageRecords.add(record);
331 }
332
333 connection.commit();
334 } catch (SQLException e) {
335 throw new RuntimeException("Failed to sync remote storage", e);
336 }
337 final var newStorageIds = newStorageRecords.stream().map(SignalStorageRecord::getId).toList();
338
339 final var manifest = new SignalStorageManifest(newVersion, account.getDeviceId(), newStorageIds);
340
341 StorageSyncValidations.validateForcePush(manifest, newStorageRecords, account.getSelfRecipientAddress());
342
343 final Optional<SignalStorageManifest> conflict;
344 try {
345 if (newVersion > 1) {
346 logger.trace("Force-pushing data. Inserting {} IDs.", newStorageRecords.size());
347 conflict = dependencies.getAccountManager()
348 .resetStorageRecords(storageServiceKey, manifest, newStorageRecords);
349 } else {
350 logger.trace("First version, normal push. Inserting {} IDs.", newStorageRecords.size());
351 conflict = dependencies.getAccountManager()
352 .writeStorageRecords(storageServiceKey, manifest, newStorageRecords, Collections.emptyList());
353 }
354 } catch (InvalidKeyException e) {
355 logger.debug("Hit an invalid key exception, which likely indicates a conflict.", e);
356 throw new RetryLaterException();
357 }
358
359 if (conflict.isPresent()) {
360 logger.debug("Hit a conflict. Trying again.");
361 throw new RetryLaterException();
362 }
363
364 logger.debug("Force push succeeded. Updating local manifest version to: " + manifest.getVersion());
365 storeManifestLocally(manifest);
366
367 try (final var connection = account.getAccountDatabase().getConnection()) {
368 connection.setAutoCommit(false);
369 account.getRecipientStore().updateStorageIds(connection, newContactStorageIds);
370 account.getGroupStore().updateStorageIds(connection, newGroupV1StorageIds, newGroupV2StorageIds);
371
372 // delete all unknown storage ids
373 account.getUnknownStorageIdStore().deleteAllUnknownStorageIds(connection);
374 connection.commit();
375 } catch (SQLException e) {
376 throw new RuntimeException("Failed to sync remote storage", e);
377 }
378 }
379
380 private Map<RecipientId, StorageId> generateContactStorageIds(List<RecipientId> recipientIds) {
381 final var selfRecipientId = account.getSelfRecipientId();
382 return recipientIds.stream().collect(Collectors.toMap(recipientId -> recipientId, recipientId -> {
383 if (recipientId.equals(selfRecipientId)) {
384 return StorageId.forAccount(KeyUtils.createRawStorageId());
385 } else {
386 return StorageId.forContact(KeyUtils.createRawStorageId());
387 }
388 }));
389 }
390
391 private Map<GroupIdV1, StorageId> generateGroupV1StorageIds(List<GroupIdV1> groupIds) {
392 return groupIds.stream()
393 .collect(Collectors.toMap(recipientId -> recipientId,
394 recipientId -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
395 }
396
397 private Map<GroupIdV2, StorageId> generateGroupV2StorageIds(List<GroupIdV2> groupIds) {
398 return groupIds.stream()
399 .collect(Collectors.toMap(recipientId -> recipientId,
400 recipientId -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
401 }
402
403 private void storeManifestLocally(
404 final SignalStorageManifest remoteManifest
405 ) {
406 account.setStorageManifestVersion(remoteManifest.getVersion());
407 account.setStorageManifest(remoteManifest);
408 }
409
410 private List<SignalStorageRecord> getSignalStorageRecords(
411 final StorageKey storageKey, final List<StorageId> storageIds
412 ) throws IOException {
413 List<SignalStorageRecord> records;
414 try {
415 records = dependencies.getAccountManager().readStorageRecords(storageKey, storageIds);
416 } catch (InvalidKeyException e) {
417 logger.warn("Failed to read storage records, ignoring.");
418 return List.of();
419 }
420 return records;
421 }
422
423 private List<StorageId> getAllLocalStorageIds(final Connection connection) throws SQLException {
424 final var storageIds = new ArrayList<StorageId>();
425 storageIds.addAll(account.getUnknownStorageIdStore().getUnknownStorageIds(connection));
426 storageIds.addAll(account.getGroupStore().getStorageIds(connection));
427 storageIds.addAll(account.getRecipientStore().getStorageIds(connection));
428 storageIds.add(account.getRecipientStore().getSelfStorageId(connection));
429 return storageIds;
430 }
431
432 private List<SignalStorageRecord> buildLocalStorageRecords(
433 final Connection connection, final List<StorageId> storageIds
434 ) throws SQLException {
435 final var records = new ArrayList<SignalStorageRecord>();
436 for (final var storageId : storageIds) {
437 final var record = buildLocalStorageRecord(connection, storageId);
438 if (record != null) {
439 records.add(record);
440 }
441 }
442 return records;
443 }
444
445 private SignalStorageRecord buildLocalStorageRecord(
446 Connection connection, StorageId storageId
447 ) throws SQLException {
448 return switch (ManifestRecord.Identifier.Type.fromValue(storageId.getType())) {
449 case ManifestRecord.Identifier.Type.CONTACT -> {
450 final var recipient = account.getRecipientStore().getRecipient(connection, storageId);
451 final var address = recipient.getAddress().getIdentifier();
452 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, address);
453 yield StorageSyncModels.localToRemoteRecord(recipient, identity, storageId.getRaw());
454 }
455 case ManifestRecord.Identifier.Type.GROUPV1 -> {
456 final var groupV1 = account.getGroupStore().getGroupV1(connection, storageId);
457 yield StorageSyncModels.localToRemoteRecord(groupV1, storageId.getRaw());
458 }
459 case ManifestRecord.Identifier.Type.GROUPV2 -> {
460 final var groupV2 = account.getGroupStore().getGroupV2(connection, storageId);
461 yield StorageSyncModels.localToRemoteRecord(groupV2, storageId.getRaw());
462 }
463 case ManifestRecord.Identifier.Type.ACCOUNT -> {
464 final var selfRecipient = account.getRecipientStore()
465 .getRecipient(connection, account.getSelfRecipientId());
466 yield StorageSyncModels.localToRemoteRecord(account.getConfigurationStore(),
467 selfRecipient,
468 account.getUsernameLink(),
469 storageId.getRaw());
470 }
471 case null, default -> throw new AssertionError("Got unknown local storage record type: " + storageId);
472 };
473 }
474
475 /**
476 * Given a list of all the local and remote keys you know about, this will
477 * return a result telling
478 * you which keys are exclusively remote and which are exclusively local.
479 *
480 * @param remoteIds All remote keys available.
481 * @param localIds All local keys available.
482 * @return An object describing which keys are exclusive to the remote data set
483 * and which keys are
484 * exclusive to the local data set.
485 */
486 private static IdDifferenceResult findIdDifference(
487 Collection<StorageId> remoteIds, Collection<StorageId> localIds
488 ) {
489 final var base64Encoder = Base64.getEncoder();
490 final var remoteByRawId = remoteIds.stream()
491 .collect(Collectors.toMap(id -> base64Encoder.encodeToString(id.getRaw()), id -> id));
492 final var localByRawId = localIds.stream()
493 .collect(Collectors.toMap(id -> base64Encoder.encodeToString(id.getRaw()), id -> id));
494
495 boolean hasTypeMismatch = remoteByRawId.size() != remoteIds.size() || localByRawId.size() != localIds.size();
496
497 final var remoteOnlyRawIds = SetUtil.difference(remoteByRawId.keySet(), localByRawId.keySet());
498 final var localOnlyRawIds = SetUtil.difference(localByRawId.keySet(), remoteByRawId.keySet());
499 final var sharedRawIds = SetUtil.intersection(localByRawId.keySet(), remoteByRawId.keySet());
500
501 for (String rawId : sharedRawIds) {
502 final var remote = remoteByRawId.get(rawId);
503 final var local = localByRawId.get(rawId);
504
505 if (remote.getType() != local.getType() && local.getType() != 0) {
506 remoteOnlyRawIds.remove(rawId);
507 localOnlyRawIds.remove(rawId);
508 hasTypeMismatch = true;
509 logger.debug("Remote type {} did not match local type {} for {}!",
510 remote.getType(),
511 local.getType(),
512 rawId);
513 }
514 }
515
516 final var remoteOnlyKeys = remoteOnlyRawIds.stream().map(remoteByRawId::get).toList();
517 final var localOnlyKeys = localOnlyRawIds.stream().map(localByRawId::get).toList();
518
519 return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
520 }
521
522 private List<StorageId> processKnownRecords(
523 final Connection connection, List<SignalStorageRecord> records
524 ) throws SQLException {
525 final var unknownRecords = new ArrayList<StorageId>();
526
527 final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor());
528 final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
529 final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
530 final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
531
532 for (final var record : records) {
533 logger.debug("Reading record of type {}", record.getType());
534 switch (ManifestRecord.Identifier.Type.fromValue(record.getType())) {
535 case ACCOUNT -> accountRecordProcessor.process(record.getAccount().get());
536 case GROUPV1 -> groupV1RecordProcessor.process(record.getGroupV1().get());
537 case GROUPV2 -> groupV2RecordProcessor.process(record.getGroupV2().get());
538 case CONTACT -> contactRecordProcessor.process(record.getContact().get());
539 case null, default -> unknownRecords.add(record.getId());
540 }
541 }
542
543 return unknownRecords;
544 }
545
546 /**
547 * hasTypeMismatches is True if there exist some keys that have matching raw ID's but different types, otherwise false.
548 */
549 private record IdDifferenceResult(
550 List<StorageId> remoteOnlyIds, List<StorageId> localOnlyIds, boolean hasTypeMismatches
551 ) {
552
553 public boolean isEmpty() {
554 return remoteOnlyIds.isEmpty() && localOnlyIds.isEmpty();
555 }
556 }
557
558 private static class RetryLaterException extends Throwable {}
559 }