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