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