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