]> nmode's Git Repositories - signal-cli/blob - src/main/java/org/asamk/textsecure/Manager.java
e5a77dcd6425aba7a7bd322b5e71305067551014
[signal-cli] / src / main / java / org / asamk / textsecure / Manager.java
1 /**
2 * Copyright (C) 2015 AsamK
3 *
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17 package org.asamk.textsecure;
18
19 import com.fasterxml.jackson.annotation.JsonAutoDetect;
20 import com.fasterxml.jackson.annotation.PropertyAccessor;
21 import com.fasterxml.jackson.databind.DeserializationFeature;
22 import com.fasterxml.jackson.databind.JsonNode;
23 import com.fasterxml.jackson.databind.ObjectMapper;
24 import com.fasterxml.jackson.databind.SerializationFeature;
25 import com.fasterxml.jackson.databind.node.ObjectNode;
26 import org.asamk.TextSecure;
27 import org.whispersystems.libaxolotl.*;
28 import org.whispersystems.libaxolotl.ecc.Curve;
29 import org.whispersystems.libaxolotl.ecc.ECKeyPair;
30 import org.whispersystems.libaxolotl.state.PreKeyRecord;
31 import org.whispersystems.libaxolotl.state.SignedPreKeyRecord;
32 import org.whispersystems.libaxolotl.util.KeyHelper;
33 import org.whispersystems.libaxolotl.util.Medium;
34 import org.whispersystems.libaxolotl.util.guava.Optional;
35 import org.whispersystems.textsecure.api.TextSecureAccountManager;
36 import org.whispersystems.textsecure.api.TextSecureMessagePipe;
37 import org.whispersystems.textsecure.api.TextSecureMessageReceiver;
38 import org.whispersystems.textsecure.api.TextSecureMessageSender;
39 import org.whispersystems.textsecure.api.crypto.TextSecureCipher;
40 import org.whispersystems.textsecure.api.messages.*;
41 import org.whispersystems.textsecure.api.push.TextSecureAddress;
42 import org.whispersystems.textsecure.api.push.TrustStore;
43 import org.whispersystems.textsecure.api.push.exceptions.EncapsulatedExceptions;
44 import org.whispersystems.textsecure.api.util.InvalidNumberException;
45 import org.whispersystems.textsecure.api.util.PhoneNumberFormatter;
46
47 import java.io.*;
48 import java.nio.file.Files;
49 import java.nio.file.Paths;
50 import java.util.*;
51 import java.util.concurrent.TimeUnit;
52 import java.util.concurrent.TimeoutException;
53
54 class Manager implements TextSecure {
55 private final static String URL = "https://textsecure-service.whispersystems.org";
56 private final static TrustStore TRUST_STORE = new WhisperTrustStore();
57
58 public final static String PROJECT_NAME = Manager.class.getPackage().getImplementationTitle();
59 public final static String PROJECT_VERSION = Manager.class.getPackage().getImplementationVersion();
60 private final static String USER_AGENT = PROJECT_NAME + " " + PROJECT_VERSION;
61
62 private final String settingsPath;
63 private final String dataPath;
64 private final String attachmentsPath;
65
66 private final ObjectMapper jsonProcessot = new ObjectMapper();
67 private String username;
68 private String password;
69 private String signalingKey;
70 private int preKeyIdOffset;
71 private int nextSignedPreKeyId;
72
73 private boolean registered = false;
74
75 private JsonAxolotlStore axolotlStore;
76 private TextSecureAccountManager accountManager;
77 private JsonGroupStore groupStore;
78
79 public Manager(String username, String settingsPath) {
80 this.username = username;
81 this.settingsPath = settingsPath;
82 this.dataPath = this.settingsPath + "/data";
83 this.attachmentsPath = this.settingsPath + "/attachments";
84
85 jsonProcessot.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); // disable autodetect
86 jsonProcessot.enable(SerializationFeature.INDENT_OUTPUT); // for pretty print, you can disable it.
87 jsonProcessot.enable(SerializationFeature.WRITE_NULL_MAP_VALUES);
88 jsonProcessot.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
89 }
90
91 public String getFileName() {
92 new File(dataPath).mkdirs();
93 return dataPath + "/" + username;
94 }
95
96 public boolean userExists() {
97 File f = new File(getFileName());
98 return !(!f.exists() || f.isDirectory());
99 }
100
101 public boolean userHasKeys() {
102 return axolotlStore != null;
103 }
104
105 private JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException {
106 JsonNode node = parent.get(name);
107 if (node == null) {
108 throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ", name));
109 }
110
111 return node;
112 }
113
114 public void load() throws IOException, InvalidKeyException {
115 JsonNode rootNode = jsonProcessot.readTree(new File(getFileName()));
116
117 username = getNotNullNode(rootNode, "username").asText();
118 password = getNotNullNode(rootNode, "password").asText();
119 if (rootNode.has("signalingKey")) {
120 signalingKey = getNotNullNode(rootNode, "signalingKey").asText();
121 }
122 if (rootNode.has("preKeyIdOffset")) {
123 preKeyIdOffset = getNotNullNode(rootNode, "preKeyIdOffset").asInt(0);
124 } else {
125 preKeyIdOffset = 0;
126 }
127 if (rootNode.has("nextSignedPreKeyId")) {
128 nextSignedPreKeyId = getNotNullNode(rootNode, "nextSignedPreKeyId").asInt();
129 } else {
130 nextSignedPreKeyId = 0;
131 }
132 axolotlStore = jsonProcessot.convertValue(getNotNullNode(rootNode, "axolotlStore"), JsonAxolotlStore.class); //new JsonAxolotlStore(in.getJSONObject("axolotlStore"));
133 registered = getNotNullNode(rootNode, "registered").asBoolean();
134 JsonNode groupStoreNode = rootNode.get("groupStore");
135 if (groupStoreNode != null) {
136 groupStore = jsonProcessot.convertValue(groupStoreNode, JsonGroupStore.class);
137 }
138 if (groupStore == null) {
139 groupStore = new JsonGroupStore();
140 }
141 accountManager = new TextSecureAccountManager(URL, TRUST_STORE, username, password, USER_AGENT);
142 }
143
144 private void save() {
145 ObjectNode rootNode = jsonProcessot.createObjectNode();
146 rootNode.put("username", username)
147 .put("password", password)
148 .put("signalingKey", signalingKey)
149 .put("preKeyIdOffset", preKeyIdOffset)
150 .put("nextSignedPreKeyId", nextSignedPreKeyId)
151 .put("registered", registered)
152 .putPOJO("axolotlStore", axolotlStore)
153 .putPOJO("groupStore", groupStore)
154 ;
155 try {
156 jsonProcessot.writeValue(new File(getFileName()), rootNode);
157 } catch (Exception e) {
158 System.err.println(String.format("Error saving file: %s", e.getMessage()));
159 }
160 }
161
162 public void createNewIdentity() {
163 IdentityKeyPair identityKey = KeyHelper.generateIdentityKeyPair();
164 int registrationId = KeyHelper.generateRegistrationId(false);
165 axolotlStore = new JsonAxolotlStore(identityKey, registrationId);
166 groupStore = new JsonGroupStore();
167 registered = false;
168 save();
169 }
170
171 public boolean isRegistered() {
172 return registered;
173 }
174
175 public void register(boolean voiceVerication) throws IOException {
176 password = Util.getSecret(18);
177
178 accountManager = new TextSecureAccountManager(URL, TRUST_STORE, username, password, USER_AGENT);
179
180 if (voiceVerication)
181 accountManager.requestVoiceVerificationCode();
182 else
183 accountManager.requestSmsVerificationCode();
184
185 registered = false;
186 save();
187 }
188
189 private static final int BATCH_SIZE = 100;
190
191 private List<PreKeyRecord> generatePreKeys() {
192 List<PreKeyRecord> records = new LinkedList<>();
193
194 for (int i = 0; i < BATCH_SIZE; i++) {
195 int preKeyId = (preKeyIdOffset + i) % Medium.MAX_VALUE;
196 ECKeyPair keyPair = Curve.generateKeyPair();
197 PreKeyRecord record = new PreKeyRecord(preKeyId, keyPair);
198
199 axolotlStore.storePreKey(preKeyId, record);
200 records.add(record);
201 }
202
203 preKeyIdOffset = (preKeyIdOffset + BATCH_SIZE + 1) % Medium.MAX_VALUE;
204 save();
205
206 return records;
207 }
208
209 private PreKeyRecord generateLastResortPreKey() {
210 if (axolotlStore.containsPreKey(Medium.MAX_VALUE)) {
211 try {
212 return axolotlStore.loadPreKey(Medium.MAX_VALUE);
213 } catch (InvalidKeyIdException e) {
214 axolotlStore.removePreKey(Medium.MAX_VALUE);
215 }
216 }
217
218 ECKeyPair keyPair = Curve.generateKeyPair();
219 PreKeyRecord record = new PreKeyRecord(Medium.MAX_VALUE, keyPair);
220
221 axolotlStore.storePreKey(Medium.MAX_VALUE, record);
222 save();
223
224 return record;
225 }
226
227 private SignedPreKeyRecord generateSignedPreKey(IdentityKeyPair identityKeyPair) {
228 try {
229 ECKeyPair keyPair = Curve.generateKeyPair();
230 byte[] signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), keyPair.getPublicKey().serialize());
231 SignedPreKeyRecord record = new SignedPreKeyRecord(nextSignedPreKeyId, System.currentTimeMillis(), keyPair, signature);
232
233 axolotlStore.storeSignedPreKey(nextSignedPreKeyId, record);
234 nextSignedPreKeyId = (nextSignedPreKeyId + 1) % Medium.MAX_VALUE;
235 save();
236
237 return record;
238 } catch (InvalidKeyException e) {
239 throw new AssertionError(e);
240 }
241 }
242
243 public void verifyAccount(String verificationCode) throws IOException {
244 verificationCode = verificationCode.replace("-", "");
245 signalingKey = Util.getSecret(52);
246 accountManager.verifyAccountWithCode(verificationCode, signalingKey, axolotlStore.getLocalRegistrationId(), false, true);
247
248 //accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
249 registered = true;
250
251 List<PreKeyRecord> oneTimePreKeys = generatePreKeys();
252
253 PreKeyRecord lastResortKey = generateLastResortPreKey();
254
255 SignedPreKeyRecord signedPreKeyRecord = generateSignedPreKey(axolotlStore.getIdentityKeyPair());
256
257 accountManager.setPreKeys(axolotlStore.getIdentityKeyPair().getPublicKey(), lastResortKey, signedPreKeyRecord, oneTimePreKeys);
258 save();
259 }
260
261
262 private static List<TextSecureAttachment> getTextSecureAttachments(List<String> attachments) throws AttachmentInvalidException {
263 List<TextSecureAttachment> textSecureAttachments = null;
264 if (attachments != null) {
265 textSecureAttachments = new ArrayList<>(attachments.size());
266 for (String attachment : attachments) {
267 try {
268 textSecureAttachments.add(createAttachment(attachment));
269 } catch (IOException e) {
270 throw new AttachmentInvalidException(attachment, e);
271 }
272 }
273 }
274 return textSecureAttachments;
275 }
276
277 private static TextSecureAttachmentStream createAttachment(String attachment) throws IOException {
278 File attachmentFile = new File(attachment);
279 InputStream attachmentStream = new FileInputStream(attachmentFile);
280 final long attachmentSize = attachmentFile.length();
281 String mime = Files.probeContentType(Paths.get(attachment));
282 return new TextSecureAttachmentStream(attachmentStream, mime, attachmentSize, null);
283 }
284
285 @Override
286 public void sendGroupMessage(String messageText, List<String> attachments,
287 byte[] groupId)
288 throws IOException, EncapsulatedExceptions, GroupNotFoundException, AttachmentInvalidException {
289 final TextSecureDataMessage.Builder messageBuilder = TextSecureDataMessage.newBuilder().withBody(messageText);
290 if (attachments != null) {
291 messageBuilder.withAttachments(getTextSecureAttachments(attachments));
292 }
293 if (groupId != null) {
294 TextSecureGroup group = TextSecureGroup.newBuilder(TextSecureGroup.Type.DELIVER)
295 .withId(groupId)
296 .build();
297 messageBuilder.asGroupMessage(group);
298 }
299 TextSecureDataMessage message = messageBuilder.build();
300
301 sendMessage(message, groupStore.getGroup(groupId).members);
302 }
303
304 public void sendQuitGroupMessage(byte[] groupId) throws GroupNotFoundException, IOException, EncapsulatedExceptions {
305 TextSecureGroup group = TextSecureGroup.newBuilder(TextSecureGroup.Type.QUIT)
306 .withId(groupId)
307 .build();
308
309 TextSecureDataMessage message = TextSecureDataMessage.newBuilder()
310 .asGroupMessage(group)
311 .build();
312
313 sendMessage(message, groupStore.getGroup(groupId).members);
314 }
315
316 public byte[] sendUpdateGroupMessage(byte[] groupId, String name, Collection<String> members, String avatarFile) throws IOException, EncapsulatedExceptions, GroupNotFoundException, AttachmentInvalidException {
317 GroupInfo g;
318 if (groupId == null) {
319 // Create new group
320 g = new GroupInfo(Util.getSecretBytes(16));
321 g.members.add(username);
322 } else {
323 g = groupStore.getGroup(groupId);
324 }
325
326 if (name != null) {
327 g.name = name;
328 }
329
330 if (members != null) {
331 for (String member : members) {
332 try {
333 g.members.add(canonicalizeNumber(member));
334 } catch (InvalidNumberException e) {
335 System.err.println("Failed to add member \"" + member + "\" to group: " + e.getMessage());
336 System.err.println("Aborting…");
337 System.exit(1);
338 }
339 }
340 }
341
342 TextSecureGroup.Builder group = TextSecureGroup.newBuilder(TextSecureGroup.Type.UPDATE)
343 .withId(g.groupId)
344 .withName(g.name)
345 .withMembers(new ArrayList<>(g.members));
346
347 if (avatarFile != null) {
348 try {
349 group.withAvatar(createAttachment(avatarFile));
350 // TODO
351 g.avatarId = 0;
352 } catch (IOException e) {
353 throw new AttachmentInvalidException(avatarFile, e);
354 }
355 }
356
357 groupStore.updateGroup(g);
358
359 TextSecureDataMessage message = TextSecureDataMessage.newBuilder()
360 .asGroupMessage(group.build())
361 .build();
362
363 sendMessage(message, g.members);
364 return g.groupId;
365 }
366
367 @Override
368 public void sendMessage(String message, List<String> attachments, String recipient)
369 throws EncapsulatedExceptions, AttachmentInvalidException, IOException {
370 List<String> recipients = new ArrayList<>(1);
371 recipients.add(recipient);
372 sendMessage(message, attachments, recipients);
373 }
374
375 @Override
376 public void sendMessage(String messageText, List<String> attachments,
377 List<String> recipients)
378 throws IOException, EncapsulatedExceptions, AttachmentInvalidException {
379 final TextSecureDataMessage.Builder messageBuilder = TextSecureDataMessage.newBuilder().withBody(messageText);
380 if (attachments != null) {
381 messageBuilder.withAttachments(getTextSecureAttachments(attachments));
382 }
383 TextSecureDataMessage message = messageBuilder.build();
384
385 sendMessage(message, recipients);
386 }
387
388 @Override
389 public void sendEndSessionMessage(List<String> recipients) throws IOException, EncapsulatedExceptions {
390 TextSecureDataMessage message = TextSecureDataMessage.newBuilder()
391 .asEndSessionMessage()
392 .build();
393
394 sendMessage(message, recipients);
395 }
396
397 private void sendMessage(TextSecureDataMessage message, Collection<String> recipients)
398 throws IOException, EncapsulatedExceptions {
399 TextSecureMessageSender messageSender = new TextSecureMessageSender(URL, TRUST_STORE, username, password,
400 axolotlStore, USER_AGENT, Optional.<TextSecureMessageSender.EventListener>absent());
401
402 Set<TextSecureAddress> recipientsTS = new HashSet<>(recipients.size());
403 for (String recipient : recipients) {
404 try {
405 recipientsTS.add(getPushAddress(recipient));
406 } catch (InvalidNumberException e) {
407 System.err.println("Failed to add recipient \"" + recipient + "\": " + e.getMessage());
408 System.err.println("Aborting sending.");
409 save();
410 return;
411 }
412 }
413
414 messageSender.sendMessage(new ArrayList<>(recipientsTS), message);
415
416 if (message.isEndSession()) {
417 for (TextSecureAddress recipient : recipientsTS) {
418 handleEndSession(recipient.getNumber());
419 }
420 }
421 save();
422 }
423
424 private TextSecureContent decryptMessage(TextSecureEnvelope envelope) {
425 TextSecureCipher cipher = new TextSecureCipher(new TextSecureAddress(username), axolotlStore);
426 try {
427 return cipher.decrypt(envelope);
428 } catch (Exception e) {
429 // TODO handle all exceptions
430 e.printStackTrace();
431 return null;
432 }
433 }
434
435 private void handleEndSession(String source) {
436 axolotlStore.deleteAllSessions(source);
437 }
438
439 public interface ReceiveMessageHandler {
440 void handleMessage(TextSecureEnvelope envelope, TextSecureContent decryptedContent, GroupInfo group);
441 }
442
443 public void receiveMessages(int timeoutSeconds, boolean returnOnTimeout, ReceiveMessageHandler handler) throws IOException {
444 final TextSecureMessageReceiver messageReceiver = new TextSecureMessageReceiver(URL, TRUST_STORE, username, password, signalingKey, USER_AGENT);
445 TextSecureMessagePipe messagePipe = null;
446
447 try {
448 messagePipe = messageReceiver.createMessagePipe();
449
450 while (true) {
451 TextSecureEnvelope envelope;
452 TextSecureContent content = null;
453 GroupInfo group = null;
454 try {
455 envelope = messagePipe.read(timeoutSeconds, TimeUnit.SECONDS);
456 if (!envelope.isReceipt()) {
457 content = decryptMessage(envelope);
458 if (content != null) {
459 if (content.getDataMessage().isPresent()) {
460 TextSecureDataMessage message = content.getDataMessage().get();
461 if (message.getGroupInfo().isPresent()) {
462 TextSecureGroup groupInfo = message.getGroupInfo().get();
463 switch (groupInfo.getType()) {
464 case UPDATE:
465 try {
466 group = groupStore.getGroup(groupInfo.getGroupId());
467 } catch (GroupNotFoundException e) {
468 group = new GroupInfo(groupInfo.getGroupId());
469 }
470
471 if (groupInfo.getAvatar().isPresent()) {
472 TextSecureAttachment avatar = groupInfo.getAvatar().get();
473 if (avatar.isPointer()) {
474 long avatarId = avatar.asPointer().getId();
475 try {
476 retrieveAttachment(avatar.asPointer());
477 group.avatarId = avatarId;
478 } catch (IOException | InvalidMessageException e) {
479 System.err.println("Failed to retrieve group avatar (" + avatarId + "): " + e.getMessage());
480 }
481 }
482 }
483
484 if (groupInfo.getName().isPresent()) {
485 group.name = groupInfo.getName().get();
486 }
487
488 if (groupInfo.getMembers().isPresent()) {
489 group.members.addAll(groupInfo.getMembers().get());
490 }
491
492 groupStore.updateGroup(group);
493 break;
494 case DELIVER:
495 try {
496 group = groupStore.getGroup(groupInfo.getGroupId());
497 } catch (GroupNotFoundException e) {
498 }
499 break;
500 case QUIT:
501 try {
502 group = groupStore.getGroup(groupInfo.getGroupId());
503 group.members.remove(envelope.getSource());
504 } catch (GroupNotFoundException e) {
505 }
506 break;
507 }
508 }
509 if (message.isEndSession()) {
510 handleEndSession(envelope.getSource());
511 }
512 if (message.getAttachments().isPresent()) {
513 for (TextSecureAttachment attachment : message.getAttachments().get()) {
514 if (attachment.isPointer()) {
515 try {
516 retrieveAttachment(attachment.asPointer());
517 } catch (IOException | InvalidMessageException e) {
518 System.err.println("Failed to retrieve attachment (" + attachment.asPointer().getId() + "): " + e.getMessage());
519 }
520 }
521 }
522 }
523 }
524 }
525 }
526 save();
527 handler.handleMessage(envelope, content, group);
528 } catch (TimeoutException e) {
529 if (returnOnTimeout)
530 return;
531 } catch (InvalidVersionException e) {
532 System.err.println("Ignoring error: " + e.getMessage());
533 }
534 }
535 } finally {
536 if (messagePipe != null)
537 messagePipe.shutdown();
538 }
539 }
540
541 public File getAttachmentFile(long attachmentId) {
542 return new File(attachmentsPath, attachmentId + "");
543 }
544
545 private File retrieveAttachment(TextSecureAttachmentPointer pointer) throws IOException, InvalidMessageException {
546 final TextSecureMessageReceiver messageReceiver = new TextSecureMessageReceiver(URL, TRUST_STORE, username, password, signalingKey, USER_AGENT);
547
548 File tmpFile = File.createTempFile("ts_attach_" + pointer.getId(), ".tmp");
549 InputStream input = messageReceiver.retrieveAttachment(pointer, tmpFile);
550
551 new File(attachmentsPath).mkdirs();
552 File outputFile = getAttachmentFile(pointer.getId());
553 OutputStream output = null;
554 try {
555 output = new FileOutputStream(outputFile);
556 byte[] buffer = new byte[4096];
557 int read;
558
559 while ((read = input.read(buffer)) != -1) {
560 output.write(buffer, 0, read);
561 }
562 } catch (FileNotFoundException e) {
563 e.printStackTrace();
564 return null;
565 } finally {
566 if (output != null) {
567 output.close();
568 output = null;
569 }
570 if (!tmpFile.delete()) {
571 System.err.println("Failed to delete temp file: " + tmpFile);
572 }
573 }
574 if (pointer.getPreview().isPresent()) {
575 File previewFile = new File(outputFile + ".preview");
576 try {
577 output = new FileOutputStream(previewFile);
578 byte[] preview = pointer.getPreview().get();
579 output.write(preview, 0, preview.length);
580 } catch (FileNotFoundException e) {
581 e.printStackTrace();
582 return null;
583 } finally {
584 if (output != null) {
585 output.close();
586 }
587 }
588 }
589 return outputFile;
590 }
591
592 private String canonicalizeNumber(String number) throws InvalidNumberException {
593 String localNumber = username;
594 return PhoneNumberFormatter.formatNumber(number, localNumber);
595 }
596
597 private TextSecureAddress getPushAddress(String number) throws InvalidNumberException {
598 String e164number = canonicalizeNumber(number);
599 return new TextSecureAddress(e164number);
600 }
601
602 @Override
603 public boolean isRemote() {
604 return false;
605 }
606 }