diff --git a/app/src/androidTest/scala/com.nutomic.ensichat.messages/CryptoTest.scala b/app/src/androidTest/scala/com.nutomic.ensichat.messages/CryptoTest.scala new file mode 100644 index 0000000..de664ef --- /dev/null +++ b/app/src/androidTest/scala/com.nutomic.ensichat.messages/CryptoTest.scala @@ -0,0 +1,24 @@ +package com.nutomic.ensichat.messages + +import android.test.AndroidTestCase +import junit.framework.Assert._ +import com.nutomic.ensichat.messages.MessageTest._ + +class CryptoTest extends AndroidTestCase { + + var encrypt: Crypto = _ + + override def setUp(): Unit = { + super.setUp() + encrypt = new Crypto(getContext.getFilesDir) + if (!encrypt.localKeysExist) { + encrypt.generateLocalKeys() + } + } + + def testSignVerify(): Unit = { + val sig = encrypt.calculateSignature(m1) + assertTrue(encrypt.isValidSignature(m1, sig, encrypt.getLocalPublicKey)) + } + +} diff --git a/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala b/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala index 3db8c68..91d2014 100644 --- a/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala +++ b/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala @@ -9,6 +9,7 @@ import android.test.AndroidTestCase import android.test.mock.MockContext import com.nutomic.ensichat.bluetooth.Device import junit.framework.Assert._ +import com.nutomic.ensichat.messages.MessageTest._ class MessageStoreTest extends AndroidTestCase { @@ -26,9 +27,9 @@ class MessageStoreTest extends AndroidTestCase { override def setUp(): Unit = { MessageStore = new MessageStore(new TestContext(getContext)) - MessageStore.addMessage(TextMessageTest.m1) - MessageStore.addMessage(TextMessageTest.m2) - MessageStore.addMessage(TextMessageTest.m3) + MessageStore.addMessage(m1) + MessageStore.addMessage(m2) + MessageStore.addMessage(m3) } override def tearDown(): Unit = { @@ -46,13 +47,13 @@ class MessageStoreTest extends AndroidTestCase { def testOrder(): Unit = { val msg = MessageStore.getMessages(new Device.ID("two"), 1) - assertTrue(msg.contains(TextMessageTest.m3)) + assertTrue(msg.contains(m3)) } def testSelect(): Unit = { val msg = MessageStore.getMessages(new Device.ID("two"), 2) - assertTrue(msg.contains(TextMessageTest.m1)) - assertTrue(msg.contains(TextMessageTest.m3)) + assertTrue(msg.contains(m1)) + assertTrue(msg.contains(m3)) } } diff --git a/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageTest.scala b/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageTest.scala new file mode 100644 index 0000000..b3b8543 --- /dev/null +++ b/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageTest.scala @@ -0,0 +1,49 @@ +package com.nutomic.ensichat.messages + +import java.io.{PipedInputStream, PipedOutputStream} +import java.util.GregorianCalendar + +import android.test.AndroidTestCase +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.MessageTest._ +import junit.framework.Assert._ + +import scala.collection.immutable.TreeSet + +object MessageTest { + + val m1 = new TextMessage(new Device.ID("one"), new Device.ID("two"), + new GregorianCalendar(2014, 10, 29).getTime, "first") + + val m2 = new TextMessage(new Device.ID("one"), new Device.ID("three"), + new GregorianCalendar(2014, 10, 30).getTime, "second") + + val m3 = new TextMessage(new Device.ID("four"), new Device.ID("two"), + new GregorianCalendar(2014, 10, 31).getTime, "third") + + +} + +class MessageTest extends AndroidTestCase { + + def testSerialize(): Unit = { + val pis = new PipedInputStream() + val pos = new PipedOutputStream(pis) + m1.write(pos, Array[Byte]()) + val (msg, _) = Message.read(pis) + assertEquals(m1, msg) + } + + def testOrder(): Unit = { + var messages = new TreeSet[Message]()(Message.Ordering) + messages += m1 + messages += m2 + assertEquals(m1, messages.firstKey) + + messages = new TreeSet[Message]()(Message.Ordering) + messages += m2 + messages += m3 + assertEquals(m2, messages.firstKey) + } + +} diff --git a/app/src/androidTest/scala/com.nutomic.ensichat.messages/TextMessageTest.scala b/app/src/androidTest/scala/com.nutomic.ensichat.messages/TextMessageTest.scala deleted file mode 100644 index cdd7d32..0000000 --- a/app/src/androidTest/scala/com.nutomic.ensichat.messages/TextMessageTest.scala +++ /dev/null @@ -1,51 +0,0 @@ -package com.nutomic.ensichat.messages - -import java.io.{PipedInputStream, PipedOutputStream} -import java.util.GregorianCalendar - -import android.test.AndroidTestCase -import com.nutomic.ensichat.bluetooth.Device -import junit.framework.Assert._ - -import scala.collection.immutable.TreeSet - -object TextMessageTest { - - val m1 = new TextMessage(new Device.ID("one"), new Device.ID("two"), "first", - new GregorianCalendar(2014, 10, 29).getTime) - - val m2 = new TextMessage(new Device.ID("one"), new Device.ID("three"), "second", - new GregorianCalendar(2014, 10, 30).getTime) - - val m3 = new TextMessage(new Device.ID("four"), new Device.ID("two"), "third", - new GregorianCalendar(2014, 10, 31).getTime) - -} - -class TextMessageTest extends AndroidTestCase { - - def testSerialize(): Unit = { - val pis = new PipedInputStream() - val pos = new PipedOutputStream(pis) - - TextMessageTest.m1.write(pos) - - val unpacked = TextMessage.fromStream(pis) - - assertEquals(TextMessageTest.m1, unpacked) - } - - def testOrder(): Unit = { - var messages = new TreeSet[TextMessage]()(TextMessage.Ordering) - messages += TextMessageTest.m1 - messages += TextMessageTest.m2 - assertEquals(TextMessageTest.m1, messages.firstKey) - - messages = new TreeSet[TextMessage]()(TextMessage.Ordering) - messages += TextMessageTest.m2 - messages += TextMessageTest.m3 - assertEquals(TextMessageTest.m2, messages.firstKey) - } - - -} diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala index 267137f..8053db5 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala @@ -1,6 +1,6 @@ package com.nutomic.ensichat.bluetooth -import java.util.UUID +import java.util.{Date, UUID} import android.app.Service import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket} @@ -9,7 +9,7 @@ import android.os.Handler import android.util.Log import com.nutomic.ensichat.R import com.nutomic.ensichat.bluetooth.ChatService.{OnDeviceConnectedListener, OnMessageReceivedListener} -import com.nutomic.ensichat.messages.{MessageStore, TextMessage} +import com.nutomic.ensichat.messages._ import scala.collection.immutable.{HashMap, HashSet, TreeSet} import scala.collection.{SortedSet, mutable} @@ -22,12 +22,14 @@ object ChatService { */ val appUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e") + val KEY_GENERATION_FINISHED = "com.nutomic.ensichat.messages.KEY_GENERATION_FINISHED" + trait OnDeviceConnectedListener { def onDeviceConnected(devices: Map[Device.ID, Device]): Unit } trait OnMessageReceivedListener { - def onMessageReceived(messages: SortedSet[TextMessage]): Unit + def onMessageReceived(messages: SortedSet[Message]): Unit } } @@ -68,6 +70,8 @@ class ChatService extends Service { private var MessageStore: MessageStore = _ + private lazy val Encrypt = new Crypto(getFilesDir) + /** * Initializes BroadcastReceiver for discovery, starts discovery and listens for connections. */ @@ -83,6 +87,14 @@ class ChatService extends Service { if (bluetoothAdapter.isEnabled) { startBluetoothConnections() } + + if (!Encrypt.localKeysExist) { + new Thread(new Runnable { + override def run(): Unit = { + Encrypt.generateLocalKeys() + } + }).start() + } } override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY @@ -120,7 +132,7 @@ class ChatService extends Service { /** * Receives newly discovered devices and connects to them. */ - private val DeviceDiscoveredReceiver: BroadcastReceiver = new BroadcastReceiver() { + private val DeviceDiscoveredReceiver = new BroadcastReceiver() { override def onReceive(context: Context, intent: Intent) { val device: Device = new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) @@ -132,7 +144,7 @@ class ChatService extends Service { /** * Starts or stops listening and discovery based on bluetooth state. */ - private val BluetoothStateReceiver: BroadcastReceiver = new BroadcastReceiver { + private val BluetoothStateReceiver = new BroadcastReceiver { override def onReceive(context: Context, intent: Intent): Unit = { intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) match { case BluetoothAdapter.STATE_ON => @@ -171,13 +183,16 @@ class ChatService extends Service { /** * Called when a Bluetooth device is connected. * - * Adds the device to [[connections]], notifies all [[deviceListeners]]. + * Adds the device to [[connections]], notifies all [[deviceListeners]], sends DeviceInfoMessage. */ def onConnected(device: Device, socket: BluetoothSocket): Unit = { val updatedDevice: Device = new Device(device.bluetoothDevice, true) devices += (device.id -> updatedDevice) - connections += (device.id -> new TransferThread(updatedDevice, socket, handleNewMessage)) + connections += (device.id -> + new TransferThread(updatedDevice, socket, localDeviceId, Encrypt, handleNewMessage)) connections(device.id).start() + + send(new DeviceInfoMessage(localDeviceId, device.id, new Date(), Encrypt.getLocalPublicKey)) deviceListeners.foreach(l => l.get match { case Some(_) => l.apply().onDeviceConnected(devices) case None => deviceListeners -= l @@ -187,7 +202,7 @@ class ChatService extends Service { /** * Sends message to the device specified as receiver, */ - def send(message: TextMessage): Unit = { + def send(message: Message): Unit = { connections.apply(message.receiver).send(message) handleNewMessage(message) } @@ -197,13 +212,13 @@ class ChatService extends Service { * * If you want to send a new message, use [[send]]. */ - def handleNewMessage(message: TextMessage): Unit = { + private def handleNewMessage(message: Message): Unit = { MessageStore.addMessage(message) MainHandler.post(new Runnable { override def run(): Unit = { messageListeners(message.sender).foreach(l => if (l.get != null) - l.apply().onMessageReceived(new TreeSet[TextMessage]()(TextMessage.Ordering) + message) + l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message) else messageListeners(message.sender) -= l) } diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala index 6a7e0d7..48f8cff 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala @@ -13,26 +13,27 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un val Tag = "ConnectThread" - val socket: BluetoothSocket = + val Socket: BluetoothSocket = device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid) override def run(): Unit = { Log.i(Tag, "Connecting to " + device.toString) try { - socket.connect() + Socket.connect() } catch { case e: IOException => + Log.w(Tag, "Failed to connect to " + device.toString, e) try { - socket.close() + Socket.close() } catch { case e2: IOException => Log.e(Tag, "Failed to close socket", e2) } - return; + return } Log.i(Tag, "Successfully connected to device " + device.name) - onConnected(device, socket) + onConnected(device, Socket) } } diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala index 9d78a1d..9761047 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala @@ -1,7 +1,6 @@ package com.nutomic.ensichat.bluetooth import android.bluetooth.BluetoothDevice -import org.msgpack.annotation.Message object Device { @@ -10,7 +9,6 @@ object Device { * * @param Id A bluetooth device address. */ - @Message class ID(private val Id: String) { override def hashCode = Id.hashCode override def equals(a: Any) = a match { diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala index f535911..0fd95bd 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala @@ -7,6 +7,8 @@ import android.util.Log /** * Listens for incoming connections from other devices. + * + * @param name Service name to broadcast. */ class ListenThread(name: String, adapter: BluetoothAdapter, onConnected: (Device, BluetoothSocket) => Unit) extends Thread { @@ -23,7 +25,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter, } override def run(): Unit = { - Log.i(Tag, "Listening for connections") + Log.i(Tag, "Listening for connections at " + adapter.getAddress) var socket: BluetoothSocket = null while (true) { @@ -44,6 +46,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter, } def cancel(): Unit = { + Log.i(Tag, "Canceling listening") try { ServerSocket.close() } catch { diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala index c04be2c..2df5ff4 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala @@ -1,20 +1,22 @@ package com.nutomic.ensichat.bluetooth -import java.io.{IOException, InputStream, OutputStream} +import java.io._ +import java.util.Date import android.bluetooth.BluetoothSocket import android.util.Log -import com.nutomic.ensichat.messages.TextMessage +import com.nutomic.ensichat.messages.{Crypto, DeviceInfoMessage, Message, TextMessage} /** * Transfers data between connnected devices. * * @param device The bluetooth device to interact with. * @param socket An open socket to the given device. + * @param encrypt Object used to handle signing and encryption of messages. * @param onReceive Called when a message was received from the other device. */ -class TransferThread(device: Device, socket: BluetoothSocket, - onReceive: (TextMessage) => Unit) extends Thread { +class TransferThread(device: Device, socket: BluetoothSocket, localDevice: Device.ID, + encrypt: Crypto, onReceive: (Message) => Unit) extends Thread { val Tag: String = "TransferThread" @@ -23,8 +25,8 @@ class TransferThread(device: Device, socket: BluetoothSocket, socket.getInputStream } catch { case e: IOException => - Log.e(Tag, "Failed to open stream", e) - null + Log.e(Tag, "Failed to open stream", e) + null } val OutStream: OutputStream = @@ -32,28 +34,64 @@ class TransferThread(device: Device, socket: BluetoothSocket, socket.getOutputStream } catch { case e: IOException => - Log.e(Tag, "Failed to open stream", e) - null + Log.e(Tag, "Failed to open stream", e) + null } override def run(): Unit = { Log.i(Tag, "Starting data transfer with " + device.toString) + // Keep listening to the InputStream while connected while (true) { try { - val msg = TextMessage.fromStream(InStream) - onReceive(msg) + val (msg, signature) = Message.read(InStream) + var messageValid = true + + if (msg.sender != device.id) { + Log.i(Tag, "Dropping message with invalid sender from " + device.id) + messageValid = false + } + + if (msg.receiver != localDevice) { + Log.i(Tag, "Dropping message with different receiver from " + device.id) + messageValid = false + } + + // Add public key for new, local device. + // Explicitly check that message was not forwarded or spoofed. + if (msg.isInstanceOf[DeviceInfoMessage] && !encrypt.havePublicKey(msg.sender) && + msg.sender == device.id) { + val dim = msg.asInstanceOf[DeviceInfoMessage] + // Permanently store public key for new local devices (also check signature). + if (msg.sender == device.id && encrypt.isValidSignature(msg, signature, dim.publicKey)) { + encrypt.addPublicKey(device.id, msg.asInstanceOf[DeviceInfoMessage].publicKey) + Log.i(Tag, "Added public key for new device " + device.name) + } + } + + if (!encrypt.isValidSignature(msg, signature)) { + Log.i(Tag, "Dropping message with invalid signature from " + device.id) + messageValid = false + } + + if (messageValid) { + msg match { + case m: TextMessage => onReceive(m) + case m: DeviceInfoMessage => encrypt.addPublicKey(msg.sender, m.publicKey) + } + } } catch { case e: IOException => - Log.e(Tag, "Disconnected from device", e) - return + Log.e(Tag, "Disconnected from device", e) + return } } } - def send(message: TextMessage): Unit = { + def send(message: Message): Unit = { try { - message.write(OutStream) + val sig = encrypt.calculateSignature(message) + message.write(OutStream, sig) } catch { case e: IOException => Log.e(Tag, "Failed to write message", e) @@ -65,7 +103,7 @@ class TransferThread(device: Device, socket: BluetoothSocket, socket.close() } catch { case e: IOException => - Log.e(Tag, "Failed to close socket", e); + Log.e(Tag, "Failed to close socket", e); } } diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala index dba148a..0250c51 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala @@ -13,7 +13,7 @@ import android.widget._ import com.nutomic.ensichat.R import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device} -import com.nutomic.ensichat.messages.TextMessage +import com.nutomic.ensichat.messages.{Message, TextMessage} import com.nutomic.ensichat.util.MessagesAdapter import scala.collection.SortedSet @@ -108,7 +108,7 @@ class ChatFragment extends ListFragment with OnClickListener val text: String = messageText.getText.toString if (!text.isEmpty) { chatService.send( - new TextMessage(chatService.localDeviceId, device, text.toString, new Date())) + new TextMessage(chatService.localDeviceId, device, new Date(), text.toString)) messageText.getText.clear() } } @@ -117,8 +117,9 @@ class ChatFragment extends ListFragment with OnClickListener /** * Displays new messages in UI. */ - override def onMessageReceived(messages: SortedSet[TextMessage]): Unit = { - messages.foreach(m => adapter.add(m)) + override def onMessageReceived(messages: SortedSet[Message]): Unit = { + messages.filter(_.isInstanceOf[TextMessage]) + .foreach(m => adapter.add(m.asInstanceOf[TextMessage])) } /** diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/Crypto.scala b/app/src/main/scala/com/nutomic/ensichat/messages/Crypto.scala new file mode 100644 index 0000000..8337d28 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/messages/Crypto.scala @@ -0,0 +1,188 @@ +package com.nutomic.ensichat.messages + +import java.io.{File, FileInputStream, FileOutputStream, IOException} +import java.security._ +import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec} + +import android.content.Context +import android.util.Log +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.Crypto._ + +object Crypto { + + /** + * Name of algorithm used for key generation. + */ + val KeyAlgorithm = "RSA" + + /** + * Number of bits for local key pair. + */ + val KeySize = 2048 + + /** + * Name of algorithm used for message signing. + */ + val SignAlgorithm = "SHA256withRSA" + +} + +/** + * Handles all cryptography related operations. + * + * @param filesDir The return value of [[android.content.Context#getFilesDir]]. + * @note We can't use [[KeyStore]], because it requires certificates, and does not work for + * private keys + */ +class Crypto(filesDir: File) { + + private val Tag = "Crypto" + + private val PrivateKeyAlias = "local-private" + + private val PublicKeyAlias = "local-public" + + /** + * Generates a new key pair using [[KeyAlgorithm]] with [[KeySize]] bits and stores the keys. + */ + def generateLocalKeys(): Unit = { + Log.i(Tag, "Generating cryptographic keys with algorithm: " + KeyAlgorithm) + val keyGen = KeyPairGenerator.getInstance(KeyAlgorithm) + keyGen.initialize(KeySize) + val keyPair = keyGen.genKeyPair() + + saveKey(PrivateKeyAlias, keyPair.getPrivate) + saveKey(PublicKeyAlias, keyPair.getPublic) + } + + /** + * Returns true if we have a public key stored for the given device. + */ + def havePublicKey(device: Device.ID): Boolean = new File(keyFolder, device.toString).exists() + + /** + * Adds a new public key for a remote device. + * + * If a key for the device already exists, nothing is done. + * + * @param device The device to wchi the key belongs. + * @param key The new key to add. + */ + def addPublicKey(device: Device.ID, key: PublicKey): Unit = { + if (!havePublicKey(device)) { + saveKey(device.toString, key) + } else { + Log.i(Tag, "Already have key for " + device.toString + ", not overwriting") + } + } + + /** + * Checks if the message was properly signed. + * + * @param message The message to verify. + * @param signature The signature that was sent + * @return True if the signature is valid. + */ + def isValidSignature(message: Message, signature: Array[Byte], key: PublicKey = null): Boolean = { + val publicKey = + if (key != null) key + else loadKey(message.sender.toString, classOf[PublicKey]) + val sig = Signature.getInstance(SignAlgorithm) + sig.initVerify(key) + sig.update(message.getBytes) + sig.verify(signature) + } + + /** + * Returns a cryptographic signature for the given message (using local private key). + */ + def calculateSignature(message: Message): Array[Byte] = { + val sig = Signature.getInstance(SignAlgorithm) + val key = loadKey(PrivateKeyAlias, classOf[PrivateKey]) + sig.initSign(key) + sig.update(message.getBytes) + sig.sign + } + + /** + * Returns true if the local private and public key exist. + */ + def localKeysExist = new File(keyFolder, PublicKeyAlias).exists() + + /** + * Returns the local public key. + */ + def getLocalPublicKey = loadKey(PublicKeyAlias, classOf[PublicKey]) + + /** + * Permanently stores the given key. + * + * The key can later be retrieved with [[loadKey]] and the same alias. + * + * @param alias Unique name under which the key should be stored. + * @param key The (private or public) key to store. + * @throws RuntimeException If a key with the given alias already exists. + */ + private def saveKey(alias: String, key: Key): Unit = { + val path = new File(keyFolder, alias) + if (path.exists()) { + throw new RuntimeException("Requested to overwrite existing key with alias " + alias + + ", aborting") + } + + keyFolder.mkdir() + var fos: Option[FileOutputStream] = None + try { + fos = Option(new FileOutputStream(path)) + fos.foreach(_.write(key.getEncoded)) + } catch { + case e: IOException => Log.w(Tag, "Failed to save key for alias " + alias, e) + } finally { + fos.foreach(_.close()) + } + } + + /** + * Loads a key that was stored with [[saveKey]]. + * + * @param alias The alias under which the key was stored. + * @param keyType The type of key, either [[PrivateKey]] or [[PublicKey]]. + * @tparam T Deduced from keyType. + * @return The key read from storage. + * @throws RuntimeException If the key does not exist. + */ + private def loadKey[T](alias: String, keyType: Class[T]): T = { + val path = new File(keyFolder, alias) + if (!path.exists()) { + throw new RuntimeException("The requested key with alias " + alias + " does not exist") + } + + var fis: Option[FileInputStream] = None + var data: Array[Byte] = null + try { + fis = Option(new FileInputStream(path)) + data = new Array[Byte](path.length().asInstanceOf[Int]) + fis.foreach(_.read(data)) + } catch { + case e: IOException => Log.e(Tag, "Failed to load key for alias " + alias, e) + } finally { + fis.foreach(_.close()) + } + val keyFactory = KeyFactory.getInstance(KeyAlgorithm) + keyType match { + case c if c == classOf[PublicKey] => + val keySpec = new X509EncodedKeySpec(data) + keyFactory.generatePublic(keySpec).asInstanceOf[T] + case c if c == classOf[PrivateKey] => + val keySpec = new PKCS8EncodedKeySpec(data) + keyFactory.generatePrivate(keySpec).asInstanceOf[T] + } + } + + /** + * Returns the folder where keys are stored. + */ + private def keyFolder = new File(filesDir, "keys") + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/DeviceInfoMessage.scala b/app/src/main/scala/com/nutomic/ensichat/messages/DeviceInfoMessage.scala new file mode 100644 index 0000000..ba14d05 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/messages/DeviceInfoMessage.scala @@ -0,0 +1,45 @@ +package com.nutomic.ensichat.messages + +import java.security.spec.X509EncodedKeySpec +import java.security.{KeyFactory, PublicKey} +import java.util.Date + +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.Message._ +import org.msgpack.packer.Packer +import org.msgpack.unpacker.Unpacker + +object DeviceInfoMessage { + + private val FieldPublicKey = "public-key" + + def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker): DeviceInfoMessage = { + val factory = KeyFactory.getInstance(Crypto.KeyAlgorithm) + val key = factory.generatePublic(new X509EncodedKeySpec(up.readByteArray())) + new DeviceInfoMessage(sender, receiver, date, key) + } + +} + +/** + * Message that contains the public key of a device. + * + * Used on first connection to a new (local) device for key exchange. + */ +class DeviceInfoMessage(override val sender: Device.ID, override val receiver: Device.ID, + override val date: Date, val publicKey: PublicKey) + extends Message(Type.DeviceInfo) { + + override def doWrite(packer: Packer) = packer.write(publicKey.getEncoded) + + override def equals(a: Any) = + super.equals(a) && a.asInstanceOf[DeviceInfoMessage].publicKey == publicKey + + override def hashCode = super.hashCode + publicKey.hashCode + + override def toString = "DeviceInfoMessage(" + sender.toString + ", " + receiver.toString + + ", " + date.toString + ", " + publicKey.toString + ")" + + override def getBytes = super.getBytes ++ publicKey.getEncoded + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala b/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala new file mode 100644 index 0000000..4786ea1 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala @@ -0,0 +1,125 @@ +package com.nutomic.ensichat.messages + +import java.io.{InputStream, OutputStream} +import java.nio.ByteBuffer +import java.util.Date + +import com.nutomic.ensichat.bluetooth.Device +import org.msgpack.ScalaMessagePack +import org.msgpack.packer.Packer + +object Message { + + /** + * Types of messages that can be transfered. + * + * There must be one type for each implementation. + */ + object Type { + val Text = 1 + val DeviceInfo = 2 + } + + /** + * Orders messages by date, oldest messages first. + */ + val Ordering = new Ordering[Message] { + override def compare(m1: Message, m2: Message) = m1.date.compareTo(m2.date) + } + + /** + * Deserializes a stream that was written by [[Message.write]] into the correct subclass. + * + * @return Deserialized message and sits signature. + */ + def read(in: InputStream): (Message, Array[Byte]) = { + val up = new ScalaMessagePack().createUnpacker(in) + val messageType = up.readInt() + val sender = new Device.ID(up.readString()) + val receiver = new Device.ID(up.readString()) + val date = new Date(up.readLong()) + val sig = up.readByteArray() + (messageType match { + case Type.Text => TextMessage.read(sender, receiver, date, up) + case Type.DeviceInfo => DeviceInfoMessage.read(sender, receiver, date, up) + }, sig) + } + +} + +/** + * Message object that can be sent between remote devices. + * + * @param messageType One of [[Message.Type]]. + */ +abstract class Message(messageType: Int) { + + /** + * Device where the message was sent from. + */ + val sender: Device.ID + + /** + * Device the message is addressed to. + */ + val receiver: Device.ID + + /** + * Timestamp of message creation. + */ + val date: Date + + /** + * Serializes this message and the given signature into stream. + */ + def write(os: OutputStream, signature: Array[Byte]): Unit = { + val packer = new ScalaMessagePack().createPacker(os) + .write(messageType) + .write(sender.toString) + .write(receiver.toString) + .write(date.getTime) + .write(signature) + doWrite(packer) + } + + /** + * Serializes any extra data for implementing classes. + */ + protected def doWrite(packer: Packer): Unit + + /** + * Returns true if objects are equal. + * + * Implementations must provide their own implementation to check the result of this + * function and their own data. + */ + override def equals(a: Any): Boolean = a match { + case o: TextMessage => + sender == o.sender && receiver == o.receiver && date == o.date + case _ => false + } + + /** + * Returns a hash code for this object. + * + * Implementations must provide their own implementation to check the result of this + * function and their own data. + */ + override def hashCode: Int = sender.hashCode + receiver.hashCode + date.hashCode + + override def toString: String + + /** + * Returns this object's data encoded as Array[Byte]. + * + * Implementations must provide their own implementation to check the result of this + * function and their own data. + */ + def getBytes: Array[Byte] = intToBytes(messageType) ++ sender.toString.getBytes ++ + receiver.toString.getBytes ++ longToBytes(date.getTime) + + private def intToBytes(i: Int) = ByteBuffer.allocate(java.lang.Integer.SIZE / 8).putInt(i).array() + + private def longToBytes(l: Long) = ByteBuffer.allocate(java.lang.Long.SIZE / 8).putLong(l).array() + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala b/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala index 52b6b29..a5901f3 100644 --- a/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala +++ b/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala @@ -40,18 +40,18 @@ class MessageStore(context: Context) extends SQLiteOpenHelper(context, MessageSt /** * Returns the count last messages for device. */ - def getMessages(device: Device.ID, count: Int): SortedSet[TextMessage] = { + def getMessages(device: Device.ID, count: Int): SortedSet[Message] = { val c: Cursor = getReadableDatabase.query(true, "messages", Array("sender", "receiver", "text", "date"), "sender = ? OR receiver = ?", Array(device.toString, device.toString), null, null, "date DESC", count.toString) - var messages: SortedSet[TextMessage] = new TreeSet[TextMessage]()(TextMessage.Ordering) + var messages: SortedSet[Message] = new TreeSet[Message]()(Message.Ordering) while (c.moveToNext()) { val m: TextMessage = new TextMessage( new Device.ID(c.getString(c.getColumnIndex("sender"))), new Device.ID(c.getString(c.getColumnIndex("receiver"))), - new String(c.getBlob(c.getColumnIndex ("text"))), - new Date(c.getLong(c.getColumnIndex("date")))) + new Date(c.getLong(c.getColumnIndex("date"))), + new String(c.getString(c.getColumnIndex ("text")))) messages += m } c.close() @@ -61,13 +61,16 @@ class MessageStore(context: Context) extends SQLiteOpenHelper(context, MessageSt /** * Inserts the given new message into the database. */ - def addMessage(message: TextMessage): Unit = { - val cv: ContentValues = new ContentValues() - cv.put("sender", message.sender.toString) - cv.put("receiver", message.receiver.toString) - cv.put("text", message.text) - cv.put("date", message.date.getTime.toString) // toString used as workaround for compile error - getWritableDatabase.insert("messages", null, cv) + def addMessage(message: Message): Unit = message match { + case msg: TextMessage => + val cv: ContentValues = new ContentValues() + cv.put("sender", msg.sender.toString) + cv.put("receiver", msg.receiver.toString) + // toString used as workaround for compile error with Long. + cv.put("date", msg.date.getTime.toString) + cv.put("text", msg.text) + getWritableDatabase.insert("messages", null, cv) + case msg: DeviceInfoMessage => // Never stored. } override def onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): Unit = { diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/TextMessage.scala b/app/src/main/scala/com/nutomic/ensichat/messages/TextMessage.scala index 7c1f201..3e94626 100644 --- a/app/src/main/scala/com/nutomic/ensichat/messages/TextMessage.scala +++ b/app/src/main/scala/com/nutomic/ensichat/messages/TextMessage.scala @@ -1,56 +1,34 @@ package com.nutomic.ensichat.messages -import java.io.{InputStream, OutputStream} import java.util.Date import com.nutomic.ensichat.bluetooth.Device -import org.msgpack.ScalaMessagePack +import com.nutomic.ensichat.messages.Message._ +import org.msgpack.packer.Packer +import org.msgpack.unpacker.Unpacker object TextMessage { - val Ordering = new Ordering[TextMessage] { - override def compare(m1: TextMessage, m2: TextMessage) = m1.date.compareTo(m2.date) - } - - /** - * Constructs a new message from stream. - */ - def fromStream(in: InputStream): TextMessage = { - val up = new ScalaMessagePack().createUnpacker(in) - new TextMessage( - new Device.ID(up.read(classOf[String])), - new Device.ID(up.read(classOf[String])), - up.read(classOf[String]), - new Date(up.read(classOf[Long]))) - } + def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker): TextMessage = + new TextMessage(sender, receiver, date, up.readString()) } /** - * Represents content and metadata that can be transferred between devices. + * Message that contains text. */ -class TextMessage(val sender: Device.ID, val receiver: Device.ID, - val text: String, val date: Date) { +class TextMessage(override val sender: Device.ID, override val receiver: Device.ID, + override val date: Date, val text: String) extends Message(Type.Text) { - /** - * Writes this object into stream. - */ - def write(os: OutputStream): Unit = { - new ScalaMessagePack().createPacker(os) - .write(sender.toString) - .write(receiver.toString) - .write(text) - .write(date.getTime) - } + override def doWrite(packer: Packer) = packer.write(text) - override def equals(a: Any) = a match { - case o: TextMessage => - sender == o.sender && receiver == o.receiver && text == o.text && date == o.date - case _ => false - } + override def equals(a: Any) = super.equals(a) && a.asInstanceOf[TextMessage].text == text - override def hashCode() = sender.hashCode + receiver.hashCode + text.hashCode + date.hashCode() + override def hashCode = super.hashCode + text.hashCode override def toString = "TextMessage(" + sender.toString + ", " + receiver.toString + - ", " + text + ", " + date.toString + ")" -} \ No newline at end of file + ", " + date.toString + ", " + text + ")" + + override def getBytes = super.getBytes ++ text.getBytes + +}