Added message signing.
Also, DeviceInfoMessage is now sent on connect for key exchange, new abstract class Message is superclass of all message implementations. Multiple minor reformats/refactorings.
This commit is contained in:
parent
bf7aab1e11
commit
4cbaf975b5
15 changed files with 561 additions and 143 deletions
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
188
app/src/main/scala/com/nutomic/ensichat/messages/Crypto.scala
Normal file
188
app/src/main/scala/com/nutomic/ensichat/messages/Crypto.scala
Normal file
|
@ -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")
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
125
app/src/main/scala/com/nutomic/ensichat/messages/Message.scala
Normal file
125
app/src/main/scala/com/nutomic/ensichat/messages/Message.scala
Normal file
|
@ -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()
|
||||
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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 + ")"
|
||||
", " + date.toString + ", " + text + ")"
|
||||
|
||||
override def getBytes = super.getBytes ++ text.getBytes
|
||||
|
||||
}
|
Reference in a new issue