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