]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
18526f2a4a4568f0104dff8eb687fe6bb267d787
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / SignalAccount.java
1 package org.asamk.signal.manager.storage;
2
3 import com.fasterxml.jackson.annotation.JsonAutoDetect;
4 import com.fasterxml.jackson.annotation.PropertyAccessor;
5 import com.fasterxml.jackson.core.JsonGenerator;
6 import com.fasterxml.jackson.core.JsonParser;
7 import com.fasterxml.jackson.databind.DeserializationFeature;
8 import com.fasterxml.jackson.databind.JsonNode;
9 import com.fasterxml.jackson.databind.ObjectMapper;
10 import com.fasterxml.jackson.databind.SerializationFeature;
11
12 import org.asamk.signal.manager.groups.GroupId;
13 import org.asamk.signal.manager.storage.contacts.ContactInfo;
14 import org.asamk.signal.manager.storage.contacts.JsonContactsStore;
15 import org.asamk.signal.manager.storage.groups.GroupInfoV1;
16 import org.asamk.signal.manager.storage.groups.JsonGroupStore;
17 import org.asamk.signal.manager.storage.messageCache.MessageCache;
18 import org.asamk.signal.manager.storage.profiles.ProfileStore;
19 import org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore;
20 import org.asamk.signal.manager.storage.protocol.SignalServiceAddressResolver;
21 import org.asamk.signal.manager.storage.recipients.LegacyRecipientStore;
22 import org.asamk.signal.manager.storage.recipients.RecipientId;
23 import org.asamk.signal.manager.storage.recipients.RecipientStore;
24 import org.asamk.signal.manager.storage.sessions.SessionStore;
25 import org.asamk.signal.manager.storage.stickers.StickerStore;
26 import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
27 import org.asamk.signal.manager.util.IOUtils;
28 import org.asamk.signal.manager.util.KeyUtils;
29 import org.asamk.signal.manager.util.Utils;
30 import org.signal.zkgroup.InvalidInputException;
31 import org.signal.zkgroup.profiles.ProfileKey;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34 import org.whispersystems.libsignal.IdentityKeyPair;
35 import org.whispersystems.libsignal.SignalProtocolAddress;
36 import org.whispersystems.libsignal.state.PreKeyRecord;
37 import org.whispersystems.libsignal.state.SessionRecord;
38 import org.whispersystems.libsignal.state.SignedPreKeyRecord;
39 import org.whispersystems.libsignal.util.Medium;
40 import org.whispersystems.libsignal.util.Pair;
41 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
42 import org.whispersystems.signalservice.api.kbs.MasterKey;
43 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
44 import org.whispersystems.signalservice.api.storage.StorageKey;
45
46 import java.io.ByteArrayInputStream;
47 import java.io.ByteArrayOutputStream;
48 import java.io.Closeable;
49 import java.io.File;
50 import java.io.IOException;
51 import java.io.RandomAccessFile;
52 import java.nio.channels.Channels;
53 import java.nio.channels.ClosedChannelException;
54 import java.nio.channels.FileChannel;
55 import java.nio.channels.FileLock;
56 import java.util.Base64;
57 import java.util.Collection;
58 import java.util.UUID;
59 import java.util.stream.Collectors;
60
61 public class SignalAccount implements Closeable {
62
63 private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class);
64
65 private final ObjectMapper jsonProcessor = new ObjectMapper();
66 private final FileChannel fileChannel;
67 private final FileLock lock;
68 private String username;
69 private UUID uuid;
70 private int deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
71 private boolean isMultiDevice = false;
72 private String password;
73 private String registrationLockPin;
74 private MasterKey pinMasterKey;
75 private StorageKey storageKey;
76 private ProfileKey profileKey;
77 private int preKeyIdOffset;
78 private int nextSignedPreKeyId;
79
80 private boolean registered = false;
81
82 private JsonSignalProtocolStore signalProtocolStore;
83 private SessionStore sessionStore;
84 private JsonGroupStore groupStore;
85 private JsonContactsStore contactStore;
86 private RecipientStore recipientStore;
87 private ProfileStore profileStore;
88 private StickerStore stickerStore;
89
90 private MessageCache messageCache;
91
92 private SignalAccount(final FileChannel fileChannel, final FileLock lock) {
93 this.fileChannel = fileChannel;
94 this.lock = lock;
95 jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); // disable autodetect
96 jsonProcessor.enable(SerializationFeature.INDENT_OUTPUT); // for pretty print
97 jsonProcessor.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
98 jsonProcessor.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE);
99 jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
100 }
101
102 public static SignalAccount load(File dataPath, String username) throws IOException {
103 final var fileName = getFileName(dataPath, username);
104 final var pair = openFileChannel(fileName);
105 try {
106 var account = new SignalAccount(pair.first(), pair.second());
107 account.load(dataPath);
108 account.migrateLegacyConfigs();
109
110 return account;
111 } catch (Throwable e) {
112 pair.second().close();
113 pair.first().close();
114 throw e;
115 }
116 }
117
118 public static SignalAccount create(
119 File dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey
120 ) throws IOException {
121 IOUtils.createPrivateDirectories(dataPath);
122 var fileName = getFileName(dataPath, username);
123 if (!fileName.exists()) {
124 IOUtils.createPrivateFile(fileName);
125 }
126
127 final var pair = openFileChannel(fileName);
128 var account = new SignalAccount(pair.first(), pair.second());
129
130 account.username = username;
131 account.profileKey = profileKey;
132 account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
133 account.contactStore = new JsonContactsStore();
134 account.recipientStore = RecipientStore.load(getRecipientsStoreFile(dataPath, username),
135 account::mergeRecipients);
136 account.sessionStore = new SessionStore(getSessionsPath(dataPath, username),
137 account.recipientStore::resolveRecipient);
138 account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId, account.sessionStore);
139 account.profileStore = new ProfileStore();
140 account.stickerStore = new StickerStore();
141
142 account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
143
144 account.registered = false;
145
146 account.migrateLegacyConfigs();
147
148 return account;
149 }
150
151 public static SignalAccount createLinkedAccount(
152 File dataPath,
153 String username,
154 UUID uuid,
155 String password,
156 int deviceId,
157 IdentityKeyPair identityKey,
158 int registrationId,
159 ProfileKey profileKey
160 ) throws IOException {
161 IOUtils.createPrivateDirectories(dataPath);
162 var fileName = getFileName(dataPath, username);
163 if (!fileName.exists()) {
164 IOUtils.createPrivateFile(fileName);
165 }
166
167 final var pair = openFileChannel(fileName);
168 var account = new SignalAccount(pair.first(), pair.second());
169
170 account.username = username;
171 account.uuid = uuid;
172 account.password = password;
173 account.profileKey = profileKey;
174 account.deviceId = deviceId;
175 account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
176 account.contactStore = new JsonContactsStore();
177 account.recipientStore = RecipientStore.load(getRecipientsStoreFile(dataPath, username),
178 account::mergeRecipients);
179 account.sessionStore = new SessionStore(getSessionsPath(dataPath, username),
180 account.recipientStore::resolveRecipient);
181 account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId, account.sessionStore);
182 account.profileStore = new ProfileStore();
183 account.stickerStore = new StickerStore();
184
185 account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
186
187 account.registered = true;
188 account.isMultiDevice = true;
189
190 account.migrateLegacyConfigs();
191
192 return account;
193 }
194
195 public void migrateLegacyConfigs() {
196 if (getProfileKey() == null && isRegistered()) {
197 // Old config file, creating new profile key
198 setProfileKey(KeyUtils.createProfileKey());
199 save();
200 }
201 // Store profile keys only in profile store
202 for (var contact : getContactStore().getContacts()) {
203 var profileKeyString = contact.profileKey;
204 if (profileKeyString == null) {
205 continue;
206 }
207 final ProfileKey profileKey;
208 try {
209 profileKey = new ProfileKey(Base64.getDecoder().decode(profileKeyString));
210 } catch (InvalidInputException ignored) {
211 continue;
212 }
213 contact.profileKey = null;
214 getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
215 }
216 // Ensure our profile key is stored in profile store
217 getProfileStore().storeProfileKey(getSelfAddress(), getProfileKey());
218 }
219
220 private void mergeRecipients(RecipientId recipientId, RecipientId toBeMergedRecipientId) {
221 sessionStore.mergeRecipients(recipientId, toBeMergedRecipientId);
222 }
223
224 public static File getFileName(File dataPath, String username) {
225 return new File(dataPath, username);
226 }
227
228 private static File getUserPath(final File dataPath, final String username) {
229 return new File(dataPath, username + ".d");
230 }
231
232 public static File getMessageCachePath(File dataPath, String username) {
233 return new File(getUserPath(dataPath, username), "msg-cache");
234 }
235
236 private static File getGroupCachePath(File dataPath, String username) {
237 return new File(getUserPath(dataPath, username), "group-cache");
238 }
239
240 private static File getSessionsPath(File dataPath, String username) {
241 return new File(getUserPath(dataPath, username), "sessions");
242 }
243
244 private static File getRecipientsStoreFile(File dataPath, String username) {
245 return new File(getUserPath(dataPath, username), "recipients-store");
246 }
247
248 public static boolean userExists(File dataPath, String username) {
249 if (username == null) {
250 return false;
251 }
252 var f = getFileName(dataPath, username);
253 return !(!f.exists() || f.isDirectory());
254 }
255
256 private void load(File dataPath) throws IOException {
257 JsonNode rootNode;
258 synchronized (fileChannel) {
259 fileChannel.position(0);
260 rootNode = jsonProcessor.readTree(Channels.newInputStream(fileChannel));
261 }
262
263 if (rootNode.hasNonNull("uuid")) {
264 try {
265 uuid = UUID.fromString(rootNode.get("uuid").asText());
266 } catch (IllegalArgumentException e) {
267 throw new IOException("Config file contains an invalid uuid, needs to be a valid UUID", e);
268 }
269 }
270 if (rootNode.hasNonNull("deviceId")) {
271 deviceId = rootNode.get("deviceId").asInt();
272 }
273 if (rootNode.hasNonNull("isMultiDevice")) {
274 isMultiDevice = rootNode.get("isMultiDevice").asBoolean();
275 }
276 username = Utils.getNotNullNode(rootNode, "username").asText();
277 password = Utils.getNotNullNode(rootNode, "password").asText();
278 if (rootNode.hasNonNull("registrationLockPin")) {
279 registrationLockPin = rootNode.get("registrationLockPin").asText();
280 }
281 if (rootNode.hasNonNull("pinMasterKey")) {
282 pinMasterKey = new MasterKey(Base64.getDecoder().decode(rootNode.get("pinMasterKey").asText()));
283 }
284 if (rootNode.hasNonNull("storageKey")) {
285 storageKey = new StorageKey(Base64.getDecoder().decode(rootNode.get("storageKey").asText()));
286 }
287 if (rootNode.hasNonNull("preKeyIdOffset")) {
288 preKeyIdOffset = rootNode.get("preKeyIdOffset").asInt(0);
289 } else {
290 preKeyIdOffset = 0;
291 }
292 if (rootNode.hasNonNull("nextSignedPreKeyId")) {
293 nextSignedPreKeyId = rootNode.get("nextSignedPreKeyId").asInt();
294 } else {
295 nextSignedPreKeyId = 0;
296 }
297 if (rootNode.hasNonNull("profileKey")) {
298 try {
299 profileKey = new ProfileKey(Base64.getDecoder().decode(rootNode.get("profileKey").asText()));
300 } catch (InvalidInputException e) {
301 throw new IOException(
302 "Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes",
303 e);
304 }
305 }
306
307 recipientStore = RecipientStore.load(getRecipientsStoreFile(dataPath, username), this::mergeRecipients);
308 var legacyRecipientStoreNode = rootNode.get("recipientStore");
309 if (legacyRecipientStoreNode != null) {
310 logger.debug("Migrating legacy recipient store.");
311 var legacyRecipientStore = jsonProcessor.convertValue(legacyRecipientStoreNode, LegacyRecipientStore.class);
312 if (legacyRecipientStore != null) {
313 recipientStore.resolveRecipients(legacyRecipientStore.getAddresses());
314 }
315 }
316
317 signalProtocolStore = jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"),
318 JsonSignalProtocolStore.class);
319 sessionStore = new SessionStore(getSessionsPath(dataPath, username), recipientStore::resolveRecipient);
320 if (signalProtocolStore.getLegacySessionStore() != null) {
321 logger.debug("Migrating legacy session store.");
322 for (var session : signalProtocolStore.getLegacySessionStore().getSessions()) {
323 try {
324 sessionStore.storeSession(new SignalProtocolAddress(session.address.getIdentifier(),
325 session.deviceId), new SessionRecord(session.sessionRecord));
326 } catch (IOException e) {
327 logger.warn("Failed to migrate session, ignoring", e);
328 }
329 }
330 }
331 signalProtocolStore.setSessionStore(sessionStore);
332 registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
333 var groupStoreNode = rootNode.get("groupStore");
334 if (groupStoreNode != null) {
335 groupStore = jsonProcessor.convertValue(groupStoreNode, JsonGroupStore.class);
336 groupStore.groupCachePath = getGroupCachePath(dataPath, username);
337 }
338 if (groupStore == null) {
339 groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
340 }
341
342 var contactStoreNode = rootNode.get("contactStore");
343 if (contactStoreNode != null) {
344 contactStore = jsonProcessor.convertValue(contactStoreNode, JsonContactsStore.class);
345 }
346 if (contactStore == null) {
347 contactStore = new JsonContactsStore();
348 }
349
350 var profileStoreNode = rootNode.get("profileStore");
351 if (profileStoreNode != null) {
352 profileStore = jsonProcessor.convertValue(profileStoreNode, ProfileStore.class);
353 }
354 if (profileStore == null) {
355 profileStore = new ProfileStore();
356 }
357
358 var stickerStoreNode = rootNode.get("stickerStore");
359 if (stickerStoreNode != null) {
360 stickerStore = jsonProcessor.convertValue(stickerStoreNode, StickerStore.class);
361 }
362 if (stickerStore == null) {
363 stickerStore = new StickerStore();
364 }
365
366 if (recipientStore.isEmpty()) {
367 recipientStore.resolveRecipient(getSelfAddress());
368
369 recipientStore.resolveRecipients(contactStore.getContacts()
370 .stream()
371 .map(ContactInfo::getAddress)
372 .collect(Collectors.toList()));
373
374 for (var group : groupStore.getGroups()) {
375 if (group instanceof GroupInfoV1) {
376 var groupInfoV1 = (GroupInfoV1) group;
377 groupInfoV1.members = groupInfoV1.members.stream()
378 .map(m -> recipientStore.resolveServiceAddress(m))
379 .collect(Collectors.toSet());
380 }
381 }
382
383 for (var identity : signalProtocolStore.getIdentities()) {
384 identity.setAddress(recipientStore.resolveServiceAddress(identity.getAddress()));
385 }
386 }
387
388 messageCache = new MessageCache(getMessageCachePath(dataPath, username));
389
390 var threadStoreNode = rootNode.get("threadStore");
391 if (threadStoreNode != null && !threadStoreNode.isNull()) {
392 var threadStore = jsonProcessor.convertValue(threadStoreNode, LegacyJsonThreadStore.class);
393 // Migrate thread info to group and contact store
394 for (var thread : threadStore.getThreads()) {
395 if (thread.id == null || thread.id.isEmpty()) {
396 continue;
397 }
398 try {
399 var contactInfo = contactStore.getContact(new SignalServiceAddress(null, thread.id));
400 if (contactInfo != null) {
401 contactInfo.messageExpirationTime = thread.messageExpirationTime;
402 contactStore.updateContact(contactInfo);
403 } else {
404 var groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id));
405 if (groupInfo instanceof GroupInfoV1) {
406 ((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
407 groupStore.updateGroup(groupInfo);
408 }
409 }
410 } catch (Exception ignored) {
411 }
412 }
413 }
414 }
415
416 public void save() {
417 if (fileChannel == null) {
418 return;
419 }
420 var rootNode = jsonProcessor.createObjectNode();
421 rootNode.put("username", username)
422 .put("uuid", uuid == null ? null : uuid.toString())
423 .put("deviceId", deviceId)
424 .put("isMultiDevice", isMultiDevice)
425 .put("password", password)
426 .put("registrationLockPin", registrationLockPin)
427 .put("pinMasterKey",
428 pinMasterKey == null ? null : Base64.getEncoder().encodeToString(pinMasterKey.serialize()))
429 .put("storageKey",
430 storageKey == null ? null : Base64.getEncoder().encodeToString(storageKey.serialize()))
431 .put("preKeyIdOffset", preKeyIdOffset)
432 .put("nextSignedPreKeyId", nextSignedPreKeyId)
433 .put("profileKey", Base64.getEncoder().encodeToString(profileKey.serialize()))
434 .put("registered", registered)
435 .putPOJO("axolotlStore", signalProtocolStore)
436 .putPOJO("groupStore", groupStore)
437 .putPOJO("contactStore", contactStore)
438 .putPOJO("profileStore", profileStore)
439 .putPOJO("stickerStore", stickerStore);
440 try {
441 try (var output = new ByteArrayOutputStream()) {
442 // Write to memory first to prevent corrupting the file in case of serialization errors
443 jsonProcessor.writeValue(output, rootNode);
444 var input = new ByteArrayInputStream(output.toByteArray());
445 synchronized (fileChannel) {
446 fileChannel.position(0);
447 input.transferTo(Channels.newOutputStream(fileChannel));
448 fileChannel.truncate(fileChannel.position());
449 fileChannel.force(false);
450 }
451 }
452 } catch (Exception e) {
453 logger.error("Error saving file: {}", e.getMessage());
454 }
455 }
456
457 private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
458 var fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
459 var lock = fileChannel.tryLock();
460 if (lock == null) {
461 logger.info("Config file is in use by another instance, waiting…");
462 lock = fileChannel.lock();
463 logger.info("Config file lock acquired.");
464 }
465 return new Pair<>(fileChannel, lock);
466 }
467
468 public void setResolver(final SignalServiceAddressResolver resolver) {
469 signalProtocolStore.setResolver(resolver);
470 }
471
472 public void addPreKeys(Collection<PreKeyRecord> records) {
473 for (var record : records) {
474 signalProtocolStore.storePreKey(record.getId(), record);
475 }
476 preKeyIdOffset = (preKeyIdOffset + records.size()) % Medium.MAX_VALUE;
477 }
478
479 public void addSignedPreKey(SignedPreKeyRecord record) {
480 signalProtocolStore.storeSignedPreKey(record.getId(), record);
481 nextSignedPreKeyId = (nextSignedPreKeyId + 1) % Medium.MAX_VALUE;
482 }
483
484 public JsonSignalProtocolStore getSignalProtocolStore() {
485 return signalProtocolStore;
486 }
487
488 public SessionStore getSessionStore() {
489 return sessionStore;
490 }
491
492 public JsonGroupStore getGroupStore() {
493 return groupStore;
494 }
495
496 public JsonContactsStore getContactStore() {
497 return contactStore;
498 }
499
500 public RecipientStore getRecipientStore() {
501 return recipientStore;
502 }
503
504 public ProfileStore getProfileStore() {
505 return profileStore;
506 }
507
508 public StickerStore getStickerStore() {
509 return stickerStore;
510 }
511
512 public MessageCache getMessageCache() {
513 return messageCache;
514 }
515
516 public String getUsername() {
517 return username;
518 }
519
520 public UUID getUuid() {
521 return uuid;
522 }
523
524 public void setUuid(final UUID uuid) {
525 this.uuid = uuid;
526 }
527
528 public SignalServiceAddress getSelfAddress() {
529 return new SignalServiceAddress(uuid, username);
530 }
531
532 public int getDeviceId() {
533 return deviceId;
534 }
535
536 public void setDeviceId(final int deviceId) {
537 this.deviceId = deviceId;
538 }
539
540 public boolean isMasterDevice() {
541 return deviceId == SignalServiceAddress.DEFAULT_DEVICE_ID;
542 }
543
544 public IdentityKeyPair getIdentityKeyPair() {
545 return signalProtocolStore.getIdentityKeyPair();
546 }
547
548 public int getLocalRegistrationId() {
549 return signalProtocolStore.getLocalRegistrationId();
550 }
551
552 public String getPassword() {
553 return password;
554 }
555
556 public void setPassword(final String password) {
557 this.password = password;
558 }
559
560 public String getRegistrationLockPin() {
561 return registrationLockPin;
562 }
563
564 public void setRegistrationLockPin(final String registrationLockPin) {
565 this.registrationLockPin = registrationLockPin;
566 }
567
568 public MasterKey getPinMasterKey() {
569 return pinMasterKey;
570 }
571
572 public void setPinMasterKey(final MasterKey pinMasterKey) {
573 this.pinMasterKey = pinMasterKey;
574 }
575
576 public StorageKey getStorageKey() {
577 if (pinMasterKey != null) {
578 return pinMasterKey.deriveStorageServiceKey();
579 }
580 return storageKey;
581 }
582
583 public void setStorageKey(final StorageKey storageKey) {
584 this.storageKey = storageKey;
585 }
586
587 public ProfileKey getProfileKey() {
588 return profileKey;
589 }
590
591 public void setProfileKey(final ProfileKey profileKey) {
592 this.profileKey = profileKey;
593 }
594
595 public byte[] getSelfUnidentifiedAccessKey() {
596 return UnidentifiedAccess.deriveAccessKeyFrom(getProfileKey());
597 }
598
599 public int getPreKeyIdOffset() {
600 return preKeyIdOffset;
601 }
602
603 public int getNextSignedPreKeyId() {
604 return nextSignedPreKeyId;
605 }
606
607 public boolean isRegistered() {
608 return registered;
609 }
610
611 public void setRegistered(final boolean registered) {
612 this.registered = registered;
613 }
614
615 public boolean isMultiDevice() {
616 return isMultiDevice;
617 }
618
619 public void setMultiDevice(final boolean multiDevice) {
620 isMultiDevice = multiDevice;
621 }
622
623 public boolean isUnrestrictedUnidentifiedAccess() {
624 // TODO make configurable
625 return false;
626 }
627
628 public boolean isDiscoverableByPhoneNumber() {
629 // TODO make configurable
630 return true;
631 }
632
633 @Override
634 public void close() throws IOException {
635 if (fileChannel.isOpen()) {
636 save();
637 }
638 synchronized (fileChannel) {
639 try {
640 lock.close();
641 } catch (ClosedChannelException ignored) {
642 }
643 fileChannel.close();
644 }
645 }
646 }