source: trunk/src/org/expeditee/auth/mail/Mail.java@ 1504

Last change on this file since 1504 was 1504, checked in by bnemhaus, 4 years ago

Revised implementation of authenticated Expeditee mail. Motivated by bugs relating to messages not being marked as read and incorrect counting of new messages for users, the Expeditee mail system has been rewritten. The new code not only does not exhibit the previous bugs but is also better engineered. Whilst the MailBay is static (which is in line with the MessageBay), the Mail class is no longer static and must be initialised for each user as they log in.

File size: 18.7 KB
Line 
1package org.expeditee.auth.mail;
2
3import java.io.File;
4import java.io.FileNotFoundException;
5import java.io.FileWriter;
6import java.io.IOException;
7import java.nio.file.Path;
8import java.nio.file.Paths;
9import java.security.InvalidKeyException;
10import java.security.Key;
11import java.security.KeyFactory;
12import java.security.KeyStoreException;
13import java.security.NoSuchAlgorithmException;
14import java.security.PrivateKey;
15import java.security.PublicKey;
16import java.security.cert.CertificateException;
17import java.security.spec.InvalidKeySpecException;
18import java.security.spec.PKCS8EncodedKeySpec;
19import java.sql.Connection;
20import java.sql.DriverManager;
21import java.sql.PreparedStatement;
22import java.sql.ResultSet;
23import java.sql.SQLException;
24import java.sql.Statement;
25import java.text.ParseException;
26import java.text.SimpleDateFormat;
27import java.util.ArrayList;
28import java.util.Arrays;
29import java.util.Base64;
30import java.util.Date;
31import java.util.HashMap;
32import java.util.List;
33import java.util.Map;
34import java.util.Scanner;
35import java.util.function.Supplier;
36
37import javax.crypto.BadPaddingException;
38import javax.crypto.Cipher;
39import javax.crypto.IllegalBlockSizeException;
40import javax.crypto.NoSuchPaddingException;
41import javax.crypto.SecretKey;
42
43import org.expeditee.auth.AuthenticatorBrowser;
44import org.expeditee.encryption.CryptographyConstants;
45import org.expeditee.gui.FrameIO;
46import org.expeditee.gui.MessageBay;
47import org.expeditee.items.Text;
48import org.expeditee.settings.identity.secrets.KeyList;
49
50public class Mail implements CryptographyConstants {
51 private String user;
52 public static final SimpleDateFormat FORMAT = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z");
53
54 public Mail(String user) {
55 this.user = user.toLowerCase();
56 }
57
58 public void sendMail(MailEntry mail, String colleagueName) {
59 // Get database file to place mail into
60 Path databaseFilePath = getDatabaseFilePath(colleagueName);
61
62 // Obtain colleagues public key
63 PublicKey publicKey = null;
64 try {
65 publicKey = AuthenticatorBrowser.getInstance().getPublicKey(colleagueName);
66 } catch (InvalidKeySpecException | NoSuchAlgorithmException | KeyStoreException | CertificateException
67 | ClassNotFoundException | IOException | SQLException e) {
68 System.err.println("Error while sending message. Unable to obtain public key for colleague "
69 + colleagueName + ". Exception message: " + e.getMessage());
70 return;
71 }
72
73 // Check we got public key
74 if (publicKey == null) {
75 System.err.println("Error while sending message. " + "Unable to obtain public key for colleague. "
76 + "Have you exchanged contact details?");
77 return;
78 }
79
80 // Send message
81 MailEntry encryptedMailEntryForSending = mail.encryptMailEntry(publicKey, () -> {
82 try {
83 return Cipher.getInstance(AsymmetricAlgorithm + AsymmetricAlgorithmParameters);
84 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
85 e.printStackTrace();
86 return null;
87 }
88 }, "");
89 encryptedMailEntryForSending.writeToMailDatabase(databaseFilePath);
90
91 // Do it all again if demo mode is on (ensures sync without having to move db
92 // files)
93 if (Boolean.getBoolean("expeditee.demo-mode")) {
94 databaseFilePath = getDatabaseFilePath(colleagueName, true);
95 encryptedMailEntryForSending.writeToMailDatabase(databaseFilePath);
96 }
97 }
98
99 public void sendOneOffMail(MailEntry mail, String colleagueName, SecretKey key) {
100 // Get database file to place mail into
101 Path databaseFilePath = getDatabaseFilePath(colleagueName);
102
103 // Send message
104 MailEntry encryptedMailEntryForSending = mail.encryptMailEntry(key, () -> {
105 try {
106 return Cipher.getInstance(SymmetricAlgorithm + SymmetricAlgorithmParameters);
107 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
108 e.printStackTrace();
109 return null;
110 }
111 }, MailEntry.SINGLE_USE_SENDER_TAG);
112 encryptedMailEntryForSending.writeToMailDatabase(databaseFilePath);
113
114 // Do it all again if demo mode is on (ensures sync without having to move db
115 // files)
116 if (Boolean.getBoolean("expeditee.demo-mode")) {
117 databaseFilePath = getDatabaseFilePath(colleagueName, true);
118 encryptedMailEntryForSending.writeToMailDatabase(databaseFilePath);
119 }
120 }
121
122 public List<MailEntry> checkMail() {
123 PrivateKey key = getPrivateKey();
124 List<Path> databasePaths = getDatabaseInboxFiles();
125 List<MailEntry> receivedMailEncrypted = loadMailFromDatabases(databasePaths);
126 List<MailEntry> receivedMail = decryptMailEntries(receivedMailEncrypted, key);
127 // TODO: delete processed messages from database files
128 touchLastAccessedFiles(databasePaths);
129 return receivedMail;
130 }
131
132 public class MailEntry {
133 private static final String SINGLE_USE_SENDER_TAG = "=";
134 private String timestamp;
135 private String sender;
136 private String receiver;
137 private String subject;
138 private String message;
139 private Map<String, String> options;
140 private Path source;
141
142 public MailEntry(String timestamp, String sender, String receiver, String subject, String message,
143 Map<String, String> options) {
144 this.timestamp = timestamp;
145 this.sender = sender;
146 this.receiver = receiver;
147 this.subject = subject;
148 this.message = message;
149 this.options = options;
150 }
151
152 //@formatter:off
153 public String getTimestamp() { return timestamp; }
154 public String getSender() { return sender; }
155 public String getReceiver() { return receiver; }
156 public String getSubject() { return subject; }
157 public String getMessage() { return message; }
158 public Map<String, String> getOptionsTextActionMap() { return options; }
159 public Path getDatabaseSource() { return source; }
160 public void setDatabaseSource(Path source) { this.source = source; }
161 public boolean isSingleUseEncryption() {
162 return sender.startsWith(MailEntry.SINGLE_USE_SENDER_TAG);
163 }
164 //@formatter:on
165
166 private MailEntry encryptMailEntry(Key key, Supplier<Cipher> generateCipher, String markSender) {
167 if (markSender == null)
168 markSender = "";
169 try {
170 // Encrypt sender, receiver, subject and message
171 Cipher cipher = generateCipher.get();
172 cipher.init(Cipher.ENCRYPT_MODE, key);
173 String sender = markSender + Base64.getEncoder().encodeToString(cipher.doFinal(getSender().toLowerCase().getBytes()));
174 cipher.init(Cipher.ENCRYPT_MODE, key);
175 String receiver = Base64.getEncoder().encodeToString(cipher.doFinal(getReceiver().getBytes()));
176 cipher.init(Cipher.ENCRYPT_MODE, key);
177 String subject = Base64.getEncoder().encodeToString(cipher.doFinal(getSubject().getBytes()));
178 cipher.init(Cipher.ENCRYPT_MODE, key);
179 String message = Base64.getEncoder().encodeToString(cipher.doFinal(getMessage().getBytes()));
180
181 // Encrypt reply options to be provided to receiver
182 Map<String, String> optionsTextActionMap = new HashMap<String, String>();
183 Map<String, String> toEncOptionsTextActionMap = getOptionsTextActionMap();
184 for (String text : toEncOptionsTextActionMap.keySet()) {
185 cipher.init(Cipher.ENCRYPT_MODE, key);
186 String textEncrypted = Base64.getEncoder().encodeToString(cipher.doFinal(text.getBytes()));
187 cipher.init(Cipher.ENCRYPT_MODE, key);
188 String toEncAction = toEncOptionsTextActionMap.get(text);
189 String actionEncrypted = Base64.getEncoder().encodeToString(cipher.doFinal(toEncAction.getBytes()));
190 optionsTextActionMap.put(textEncrypted, actionEncrypted);
191 }
192
193 return new MailEntry(getTimestamp(), sender, receiver, subject, message, optionsTextActionMap);
194 } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
195 e.printStackTrace();
196 return null;
197 }
198 }
199
200 private void writeToMailDatabase(Path databaseFile) {
201 try {
202 Connection c = DriverManager.getConnection("jdbc:sqlite:" + databaseFile);
203 String sql = "INSERT INTO EXPMAIL (TIME,SND,REC,SUB,MSG,OPTS,OPTSVAL) VALUES (?, ?, ?, ?, ?, ?, ?);";
204 PreparedStatement statement = c.prepareStatement(sql);
205 statement.setString(1, getTimestamp());
206 statement.setString(2, getSender());
207 statement.setString(3, getReceiver());
208 statement.setString(4, getSubject());
209 statement.setString(5, message);
210 String opts = Arrays.toString(getOptionsTextActionMap().keySet().toArray());
211 statement.setString(6, opts);
212 String optsVal = Arrays.toString(getOptionsTextActionMap().values().toArray());
213 statement.setString(7, optsVal);
214 statement.execute();
215 statement.close();
216 } catch (SQLException e) {
217 e.printStackTrace();
218 }
219 }
220
221 private MailEntry decryptMailEntry(Key key, Supplier<Cipher> generateCipher, String senderMarkFilter) {
222 Cipher cipher = generateCipher.get();
223 try {
224 // Confirm this is a message is intended for this recipient
225 cipher.init(Cipher.DECRYPT_MODE, key);
226 byte[] receiverBytes = Base64.getDecoder().decode(getReceiver());
227 String receiver = new String(cipher.doFinal(receiverBytes));
228 if (!receiver.toLowerCase().equals(user)) {
229 return null;
230 }
231
232 // Decrypt remainder of message
233 cipher.init(Cipher.DECRYPT_MODE, key);
234 byte[] senderBytes = Base64.getDecoder().decode(getSender());
235 String sender = new String(cipher.doFinal(senderBytes));
236 cipher.init(Cipher.DECRYPT_MODE, key);
237 byte[] subjectBytes = Base64.getDecoder().decode(getSubject());
238 String subject = new String(cipher.doFinal(subjectBytes));
239
240 cipher.init(Cipher.DECRYPT_MODE, key);
241 byte[] messageBytes = Base64.getDecoder().decode(getMessage());
242 String message = new String(cipher.doFinal(messageBytes));
243
244 Map<String, String> options = new HashMap<String, String>();
245 for (String text : getOptionsTextActionMap().keySet()) {
246 cipher.init(Cipher.DECRYPT_MODE, key);
247 byte[] textBytes = Base64.getDecoder().decode(text);
248 String k = new String(cipher.doFinal(textBytes));
249 cipher.init(Cipher.DECRYPT_MODE, key);
250 byte[] actionBytes = Base64.getDecoder().decode(getOptionsTextActionMap().get(text));
251 String v = new String(cipher.doFinal(actionBytes));
252 options.put(k, v);
253 }
254
255 return new MailEntry(getTimestamp(), sender, receiver, subject, message, options);
256 } catch (IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) {
257 e.printStackTrace();
258 return null;
259 }
260 }
261
262 public String toString() {
263 StringBuilder sb = new StringBuilder("MailEntry { ");
264
265 sb.append("[timestamp: " + getTimestamp() + "], ");
266 sb.append("[sender: " + getSender() + "], ");
267 sb.append("[receiver: " + getReceiver() + "], ");
268 sb.append("[subject: " + getSubject() + "], ");
269 sb.append("[message: " + getMessage() + "], ");
270
271 sb.append(" }");
272 return sb.toString();
273 }
274 }
275
276 private Path getDatabaseFilePath(String colleagueName) {
277 return getDatabaseFilePath(colleagueName, false);
278 }
279
280 private Path getDatabaseFilePath(String colleagueName, boolean demoMode) {
281 String deadDropsPath = Paths.get(FrameIO.PARENT_FOLDER).resolve("resources-" + user).resolve("deaddrops").toAbsolutePath().toString();//FrameIO.DEAD_DROPS_PATH;
282
283// if (deadDropsPath == null) {
284// deadDropsPath = Paths.get(FrameIO.PARENT_FOLDER).resolve("resources-" + colleagueName).resolve("deadrops").toAbsolutePath().toString();
285// } else if (demoMode) {
286// deadDropsPath = deadDropsPath.replace(user, colleagueName);
287// }
288
289 if (demoMode) {
290 deadDropsPath = deadDropsPath.replace(user, colleagueName);
291 }
292
293 Path databaseFileDirectoryPath = Paths.get(deadDropsPath).resolve(user + "+" + colleagueName);
294 if (!databaseFileDirectoryPath.toFile().exists()) {
295 databaseFileDirectoryPath = Paths.get(deadDropsPath).resolve(colleagueName + "+" + user);
296 }
297
298 if (!databaseFileDirectoryPath.toFile().exists()) {
299 databaseFileDirectoryPath.toFile().mkdirs();
300 }
301
302 Path databaseFilePath = databaseFileDirectoryPath.resolve(colleagueName + ".db");
303 if (!databaseFilePath.toFile().exists()) {
304 createMailDatabaseFile(databaseFilePath);
305 }
306 return databaseFilePath;
307 }
308
309 private void createMailDatabaseFile(Path databaseFilePath) {
310 databaseFilePath.getParent().toFile().mkdir();
311 //@formatter:off
312 String sql =
313 "CREATE TABLE EXPMAIL (" +
314 "TIME TEXT NOT NULL, " +
315 "SND TEXT NOT NULL, " +
316 "REC TEXT NOT NULL, " +
317 "SUB TEXT NOT NULL, " +
318 "MSG TEXT NOT NULL, " +
319 "OPTS ARRAY NOT NULL, " +
320 "OPTSVAL ARRAY NOT NULL)";
321 //@formatter:on
322 try (Connection c = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath.toAbsolutePath())) {
323 Statement createTable = c.createStatement();
324 createTable.executeUpdate(sql);
325 createTable.close();
326 } catch (SQLException e) {
327 System.err.println("Error while creating database file.");
328 e.printStackTrace();
329 }
330 }
331
332 private PrivateKey getPrivateKey() {
333 Text keyItem = KeyList.PrivateKey.get();
334 String noKeyMessage = "No private key present: your communication with other Expeditee users will be limited until this is resolved.";
335 if (keyItem.getData() != null) {
336 // Check mail.
337 String keyEncoded = keyItem.getData().get(0);
338 byte[] keyBytes = Base64.getDecoder().decode(keyEncoded);
339 try {
340 return KeyFactory.getInstance(AsymmetricAlgorithm).generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
341 } catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
342 MessageBay.errorMessage(noKeyMessage);
343 MessageBay.errorMessage("See exception stack trace for more information.");
344 e.printStackTrace();
345 return null;
346 }
347 } else {
348 MessageBay.errorMessage(noKeyMessage);
349 return null;
350 }
351 }
352
353 private List<Path> getDatabaseInboxFiles() {
354 List<Path> databaseFiles = new ArrayList<Path>();
355 Path deadDropPath = Paths.get(FrameIO.DEAD_DROPS_PATH);
356 File deadDropDirectory = deadDropPath.toFile();
357 if (!deadDropDirectory.exists()) { return databaseFiles; }
358 for (File partnershipDirectoryCanditate : deadDropDirectory.listFiles()) {
359 if (partnershipDirectoryCanditate.isDirectory()) {
360 Path partnershipDirectory = Paths.get(partnershipDirectoryCanditate.getAbsolutePath());
361 Path dbFile = partnershipDirectory.resolve(user + ".db");
362 if (dbFile.toFile().exists()) {
363 databaseFiles.add(dbFile);
364 }
365 }
366 }
367 return databaseFiles;
368 }
369
370 private List<MailEntry> loadMailFromDatabases(List<Path> databasePaths) {
371 List<MailEntry> mail = new ArrayList<MailEntry>();
372
373 for (Path dbPath : databasePaths) {
374 try {
375 mail.addAll(loadMailFromDatabase(dbPath));
376 } catch (SQLException e) {
377 e.printStackTrace();
378 }
379 }
380
381 return mail;
382 }
383
384 private List<MailEntry> loadMailFromDatabase(Path dbPath) throws SQLException {
385 List<MailEntry> mail = new ArrayList<MailEntry>();
386
387 Connection c = DriverManager.getConnection("jdbc:sqlite:" + dbPath.toAbsolutePath());
388 String sql = "SELECT * FROM EXPMAIL";
389 PreparedStatement query = c.prepareStatement(sql);
390 ResultSet results = query.executeQuery();
391
392 while (results.next()) {
393 String timestamp = results.getString("TIME");
394
395 Path lastAccessedPath = dbPath.getParent().resolve(user + ".last-accessed");
396 try (Scanner in = new Scanner(lastAccessedPath.toFile())) {
397 Date lastAccessedTimestamp = FORMAT.parse(in.nextLine());
398 Date mailTimestamp = FORMAT.parse(timestamp);
399 if (mailTimestamp.before(lastAccessedTimestamp)) {
400 continue;
401 }
402 } catch (FileNotFoundException | ParseException e) {
403 // It may not have been created yet, then err on the safe side and add it in.
404 // If it fails to parse then we again err on the safe side.
405 } catch (RuntimeException e) {
406 e.printStackTrace();
407 continue;
408 }
409
410 String from = results.getString("SND");
411 String to = results.getString("REC");
412 String subject = results.getString("SUB");
413 String message = results.getString("MSG");
414 String[] opts = results.getString("opts").split(",");
415 opts[0] = opts[0].replace("[", "");
416 opts[opts.length - 1] = opts[opts.length - 1].replace("]", "");
417 String[] optsVal = results.getString("optsval").split(",");
418 optsVal[0] = optsVal[0].replace("[", "");
419 optsVal[optsVal.length - 1] = optsVal[optsVal.length - 1].replace("]", "");
420 Map<String, String> options = new HashMap<String, String>();
421 for (int i = 0; i < opts.length; i++) {
422 String key = opts[i].trim();
423 String val = optsVal[i].trim();
424 options.put(key, val);
425 }
426
427 MailEntry mailEntry = new MailEntry(timestamp, from, to, subject, message, options);
428 mailEntry.setDatabaseSource(dbPath);
429 mail.add(mailEntry);
430 }
431
432 results.close();
433 query.close();
434 c.close();
435
436 return mail;
437 }
438
439 private List<MailEntry> decryptMailEntries(List<MailEntry> encryptedMailMessages, PrivateKey key) {
440 List<MailEntry> mail = new ArrayList<MailEntry>();
441
442 for (MailEntry encMail : encryptedMailMessages) {
443 if (encMail.isSingleUseEncryption()) {
444 MailEntry oneOffSecureSubstitute = generateOneOffSecureSubstitute(encMail);
445 if (oneOffSecureSubstitute != null) {
446 mail.add(oneOffSecureSubstitute);
447 }
448 } else {
449 MailEntry decryptMailEntry = encMail.decryptMailEntry(key, () -> {
450 try {
451 return Cipher.getInstance(AsymmetricAlgorithm + AsymmetricAlgorithmParameters);
452 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
453 e.printStackTrace();
454 return null;
455 }
456 }, "");
457 if (decryptMailEntry != null) {
458 mail.add(decryptMailEntry);
459 }
460 }
461 }
462
463 return mail;
464 }
465
466 private void touchLastAccessedFiles(List<Path> databasePaths) {
467 for (Path databasePath : databasePaths) {
468 Path lastAccessedFilePath = databasePath.getParent().resolve(user + ".last-accessed");
469 try (FileWriter out = new FileWriter(lastAccessedFilePath.toFile())) {
470 out.write(Mail.FORMAT.format(new Date()) + System.getProperty("line.separator"));
471 } catch (IOException e) {
472 e.printStackTrace();
473 }
474 }
475 }
476
477 private MailEntry generateOneOffSecureSubstitute(MailEntry toWrap) {
478 StringBuilder sb = new StringBuilder();
479 String sep = ":::";
480 sb.append("Read one-off secure message." + sep);
481 sb.append(toWrap.timestamp + sep);
482 sb.append(toWrap.sender.substring(1) + sep);
483 sb.append(toWrap.receiver + sep);
484 sb.append(toWrap.subject + sep);
485 sb.append(toWrap.message + sep);
486 for (String k: toWrap.options.keySet()) {
487 sb.append(k + sep + toWrap.options.get(k) + sep);
488 }
489 sb.reverse().delete(0, 3).reverse();
490 Map<String, String> options = new HashMap<String, String>();
491 options.put(sb.toString(), "AuthOneOffSecureMessage");
492 MailEntry mailOuter = new MailEntry(toWrap.timestamp, "Unknown", user, "You have received a one-off secure message.",
493 "Check your email for more details.", options);
494 return mailOuter;
495 }
496
497 public String getUser() {
498 return user;
499 }
500}
Note: See TracBrowser for help on using the repository browser.