Use key fingerprints instead of bluetooth addresses.

This commit is contained in:
Felix Ableitner 2014-12-04 20:33:12 +02:00
parent 5f123afcab
commit de86f5f121
33 changed files with 840 additions and 347 deletions

133
PROTOCOL.md Normal file
View File

@ -0,0 +1,133 @@
Introduction and Definitions
----------------------------
This protocol is used by two or more devices forming a mesh net.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in RFC 2119.
A node is a single device implementing this protocol. Each node has
exactl one node address.
A node address consists of 32 bytes and is the SHA-256 hash of the
node's public key.
The broadcast address is
`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF`
(i.e. all bits set).
The null address is
`0x0000000000000000000000000000000000000000000000000000000000000000`
(i.e. no bits set).
Nodes MUST NOT use a public key with the broadcast address or null
address as hash (they must generate a new key pair). Also, other
nodes MUST NOT connect to a node with either address.
Messages
--------
### Header
Every message starts with one 32 bit word indicating the message
version, type and ID, followed by the length of the message. The
header is in network byte order, i.e. big endian.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Ver | Type | Hop Limit | Hop Count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Time |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Origin Address |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Target Address |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number | Metric | Reserved |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Ver specifies the protocol version number. This is currently 0. A
message with unknown version number MUST be ignored. The connection
where such a packet came from MAY be closed.
Hop Limit SHOULD be set to `MAX_HOP_COUNT` on message creation, and
MUST NOT be changed by a forwarding node.
Hop Count specifies the number of nodes a message may pass. When
creating a package, it is initialized to 0. Whenever a node forwards
a package, it MUST increment the hop limit by one. If the hop limit
BEFORE/AFTER? incrementing equals Hop Limit, the package MUST be
ignored.
Length is the message size in bytes, including the header.
Time is the unix timestamp of message creation, in seconds, as a
signed integer.
Origin Address is the address of the node that initially created the
message.
Target Address is the address of the node that should receive the
message.
Sequence number is the sequence number of either the source or target
node for this message, depending on type.
ConnectionInfo (Type = 0)
---------
After successfully connecting to a node via Bluetooth, public keys
must be exchanged. Each node MUST send this as the first message over
the connection. Hop Limit MUST be 1 for this message type (i.e. it
must never be forwarded). Origin Address and Target Address MUST be
set to all zeros, and MUST be ignored by the receiving node.
A receiving node SHOULD store the key in permanent storage if it
hasn't already stored it earlier. This key is to be used for message
encryption when communicating with the sending node.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Key Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
\ Key (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Key length is the size of the key in bytes.
Key is the public key of the sending node.
After this message has been received, communication with normal messages
may start.
### Data (Data Transfer, Type = 255)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
\ Data (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Length is the number of bytes in data.
Data is any binary data that should be transported.
This message type is deprecated.

View File

@ -20,9 +20,10 @@ dependencies {
compile("org.msgpack:msgpack-scala_2.11:0.6.11") {
transitive = false;
}
compile('org.msgpack:msgpack:0.6.11'){
compile('org.msgpack:msgpack:0.6.11') {
transitive = false;
}
compile 'com.google.guava:guava:18.0'
}
@ -50,13 +51,14 @@ android {
buildTypes {
thin {
minifyEnabled false
applicationIdSuffix ".debug"
minifyEnabled false
signingConfig signingConfigs.debug
}
debug {
minifyEnabled false
applicationIdSuffix ".debug"
minifyEnabled true
proguardFile file("proguard-rules.pro")
}
release {
minifyEnabled true

View File

@ -22,7 +22,7 @@ class MainActivityTest extends ActivityUnitTestCase[MainActivity](classOf[MainAc
override def bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean = false
override def unbindService(conn: ServiceConnection): Unit = null
override def unbindService(conn: ServiceConnection): Unit = {}
}
override def setUp(): Unit = {

View File

@ -0,0 +1,35 @@
package com.nutomic.ensichat.aodvv2
import android.test.AndroidTestCase
import com.nutomic.ensichat.aodvv2.AddressTest._
import junit.framework.Assert._
object AddressTest {
val a1 = new Address("A51B74475EE622C3C924DB147668F85E024CA0B44CA146B5E3D3C31A54B34C1E")
val a2 = new Address("222229685A73AB8F2F853B3EA515633B7CD5A6ABDC3210BC4EF38F955A14AAF6")
val a3 = new Address("3333359893F8810C4024CFC951374AABA1F4DE6347A3D7D8E44918AD1FF2BA36")
val a4 = new Address("4444459893F8810C4024CFC951374AABA1F4DE6347A3D7D8E44918AD1FF2BA36")
val a1Binary: Array[Byte] = Array(-91, 27, 116, 71, 94, -26, 34, -61, -55, 36, -37, 20, 118, 104, -8, 94, 2, 76, -96, -76, 76, -95, 70, -75, -29, -45, -61, 26, 84, -77, 76, 30).map(_.toByte)
}
class AddressTest extends AndroidTestCase {
def testEncode(): Unit = {
Set(Address.Broadcast, Address.Null, a1, a2, a3, a4).foreach{a =>
val base32 = a.toString
val read = new Address(base32)
assertEquals(a, read)
assertEquals(a.hashCode, read.hashCode)
}
assertEquals(a1, new Address(a1Binary))
assertEquals(a1Binary.deep, a1.Bytes.deep)
}
}

View File

@ -0,0 +1,28 @@
package com.nutomic.ensichat.aodvv2
import android.content.Context
import android.test.AndroidTestCase
import com.nutomic.ensichat.messages.Crypto
import junit.framework.Assert
object ConnectionInfoTest {
def generateCi(context: Context) = {
val crypto = new Crypto(context)
if (!crypto.localKeysExist)
crypto.generateLocalKeys()
new ConnectionInfo(crypto.getLocalPublicKey)
}
}
class ConnectionInfoTest extends AndroidTestCase {
def testWriteRead(): Unit = {
val ci = ConnectionInfoTest.generateCi(getContext)
val bytes = ci.write
val body = ConnectionInfo.read(bytes)
Assert.assertEquals(ci.key, body.asInstanceOf[ConnectionInfo].key)
}
}

View File

@ -0,0 +1,30 @@
package com.nutomic.ensichat.aodvv2
import java.util.Date
import android.test.AndroidTestCase
import junit.framework.Assert
object MessageHeaderTest {
val h1 = new MessageHeader(Data.Type, MessageHeader.DefaultHopLimit, new Date(), AddressTest.a3,
AddressTest.a4, 456, 123)
val h2 = new MessageHeader(0xfff, 0, new Date(0xffffffff), Address.Null, Address.Broadcast, 0,
0xff)
val h3 = new MessageHeader(0xfff, 0xff, new Date(0), Address.Broadcast, Address.Null, 0xffff, 0)
}
class MessageHeaderTest extends AndroidTestCase {
def testSerialize(): Unit = {
val ci = ConnectionInfoTest.generateCi(getContext)
val bytes = MessageHeaderTest.h1.write(ci)
val header = MessageHeader.read(bytes)
Assert.assertEquals(MessageHeaderTest.h1, header)
Assert.assertEquals(bytes.length, header.Length)
}
}

View File

@ -1,15 +1,12 @@
package com.nutomic.ensichat.messages
import java.util.GregorianCalendar
import android.test.AndroidTestCase
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.messages.MessageTest._
import junit.framework.Assert._
class CryptoTest extends AndroidTestCase {
lazy val Crypto: Crypto = new Crypto(getContext.getFilesDir)
lazy val Crypto: Crypto = new Crypto(getContext)
override def setUp(): Unit = {
super.setUp()
@ -24,14 +21,10 @@ class CryptoTest extends AndroidTestCase {
}
def testEncryptDecrypt(): Unit = {
val in = new DeviceInfoMessage(new Device.ID("DD:DD:DD:DD:DD:DD"),
new Device.ID("CC:CC:CC:CC:CC:CC"), new GregorianCalendar(2014, 10, 31).getTime,
Crypto.getLocalPublicKey)
val (encrypted, key) = Crypto.encrypt(null, in.write(Array[Byte]()), Crypto.getLocalPublicKey)
val (encrypted, key) =
Crypto.encrypt(null, MessageTest.m1.write(Array[Byte]()), Crypto.getLocalPublicKey)
val decrypted = Crypto.decrypt(encrypted, key)
val out = Message.read(decrypted)._1.asInstanceOf[DeviceInfoMessage]
assertEquals(in, out)
assertEquals(MessageTest.m1, Message.read(decrypted)._1)
}
}

View File

@ -4,7 +4,7 @@ import java.io.{PipedInputStream, PipedOutputStream}
import java.util.GregorianCalendar
import android.test.AndroidTestCase
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.AddressTest
import com.nutomic.ensichat.messages.MessageTest._
import junit.framework.Assert._
@ -12,13 +12,13 @@ import scala.collection.immutable.TreeSet
object MessageTest {
val m1 = new TextMessage(new Device.ID("AA:AA:AA:AA:AA:AA"), new Device.ID("BB:BB:BB:BB:BB:BB"),
val m1 = new TextMessage(AddressTest.a1, AddressTest.a2,
new GregorianCalendar(2014, 10, 29).getTime, "first")
val m2 = new TextMessage(new Device.ID("AA:AA:AA:AA:AA:AA"), new Device.ID("CC:CC:CC:CC:CC:CC"),
val m2 = new TextMessage(AddressTest.a1, AddressTest.a3,
new GregorianCalendar(2014, 10, 30).getTime, "second")
val m3 = new TextMessage(new Device.ID("DD:DD:DD:DD:DD:DD"), new Device.ID("BB:BB:BB:BB:BB:BB"),
val m3 = new TextMessage(AddressTest.a4, AddressTest.a2,
new GregorianCalendar(2014, 10, 31).getTime, "third")
}
@ -26,23 +26,25 @@ object MessageTest {
class MessageTest extends AndroidTestCase {
def testSerialize(): Unit = {
val pis = new PipedInputStream()
val pos = new PipedOutputStream(pis)
val bytes = m1.write(Array[Byte]())
val (msg, _) = Message.read(bytes)
assertEquals(m1, msg)
Set(m1, m2, m3).foreach { m =>
val pis = new PipedInputStream()
val pos = new PipedOutputStream(pis)
val bytes = m.write(Array[Byte]())
val (msg, _) = Message.read(bytes)
assertEquals(m, msg)
}
}
def testOrder(): Unit = {
var messages = new TreeSet[Message]()(Message.Ordering)
messages += m1
messages += m2
assertEquals(m1, messages.firstKey)
messages += MessageTest.m1
messages += MessageTest.m2
assertEquals(MessageTest.m1, messages.firstKey)
messages = new TreeSet[Message]()(Message.Ordering)
messages += m2
messages += m3
assertEquals(m2, messages.firstKey)
messages += MessageTest.m2
messages += MessageTest.m3
assertEquals(MessageTest.m2, messages.firstKey)
}
}

View File

@ -8,8 +8,8 @@ import android.database.DatabaseErrorHandler
import android.database.sqlite.SQLiteDatabase
import android.test.AndroidTestCase
import android.test.mock.MockContext
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.messages.MessageTest._
import com.nutomic.ensichat.aodvv2.AddressTest
import com.nutomic.ensichat.messages.MessageTest
import junit.framework.Assert._
class DatabaseTest extends AndroidTestCase {
@ -27,9 +27,9 @@ class DatabaseTest extends AndroidTestCase {
private lazy val Database = new Database(new TestContext(getContext))
override def setUp(): Unit = {
Database.addMessage(m1)
Database.addMessage(m2)
Database.addMessage(m3)
Database.addMessage(MessageTest.m1)
Database.addMessage(MessageTest.m2)
Database.addMessage(MessageTest.m3)
}
override def tearDown(): Unit = {
@ -38,43 +38,38 @@ class DatabaseTest extends AndroidTestCase {
}
def testMessageCount(): Unit = {
val msg1 = Database.getMessages(m1.sender, 1)
val msg1 = Database.getMessages(MessageTest.m1.sender, 1)
assertEquals(1, msg1.size)
val msg2 = Database.getMessages(m1.sender, 3)
val msg2 = Database.getMessages(MessageTest.m1.sender, 3)
assertEquals(2, msg2.size)
}
def testMessageOrder(): Unit = {
val msg = Database.getMessages(m1.receiver, 1)
assertTrue(msg.contains(m3))
val msg = Database.getMessages(MessageTest.m1.receiver, 1)
assertTrue(msg.contains(MessageTest.m3))
}
def testMessageSelect(): Unit = {
val msg = Database.getMessages(m1.receiver, 2)
assertTrue(msg.contains(m1))
assertTrue(msg.contains(m3))
val msg = Database.getMessages(MessageTest.m1.receiver, 2)
assertTrue(msg.contains(MessageTest.m1))
assertTrue(msg.contains(MessageTest.m3))
}
def testAddContact(): Unit = {
val device = new Device(m1.sender, "device", false)
Database.addContact(device)
assertTrue(Database.isContact(device.Id))
Database.addContact(AddressTest.a1)
assertTrue(Database.isContact(AddressTest.a1))
val contacts = Database.getContacts
assertEquals(1, contacts.size)
contacts.foreach {d =>
assertEquals(device.Name, d.Name)
assertEquals(device.Id, d.Id)
}
contacts.foreach{assertEquals(AddressTest.a1, _)}
}
def testAddContactCallback(): Unit = {
val device = new Device(m1.sender, "device", false)
var latch = new CountDownLatch(1)
val latch = new CountDownLatch(1)
Database.runOnContactsUpdated(() => {
latch.countDown()
})
Database.addContact(device)
Database.addContact(AddressTest.a1)
latch.await()
}

View File

@ -22,12 +22,6 @@
<string name="exit">Exit</string>
<!-- ChatFragment -->
<!-- Toast shown when trying to send a message to an offline contact -->
<string name="contact_offline_toast">Contact is offline, message not sent</string>
<!-- AddContactsActivity -->
<!-- Activity title -->

View File

@ -12,8 +12,9 @@ import android.view._
import android.widget.AdapterView.OnItemClickListener
import android.widget._
import com.nutomic.ensichat.R
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.bluetooth.ChatService
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener
import com.nutomic.ensichat.bluetooth.{ChatService, Device}
import com.nutomic.ensichat.messages.{Crypto, Message, RequestAddContactMessage, ResultAddContactMessage}
import com.nutomic.ensichat.util.{DevicesAdapter, IdenticonGenerator}
@ -24,7 +25,7 @@ import scala.collection.SortedSet
*
* Adding a contact requires confirmation on both sides.
*/
class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnectionChangedListener
class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyContactsChangedListener
with OnItemClickListener with OnMessageReceivedListener {
private val Tag = "AddContactsActivity"
@ -33,12 +34,12 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
private lazy val Database = service.database
private lazy val Crypto = new Crypto(this.getFilesDir)
private lazy val Crypto = new Crypto(this)
/**
* Map of devices that should be added.
*/
private var currentlyAdding = Map[Device.ID, AddContactInfo]()
private var currentlyAdding = Map[Address, AddContactInfo]()
.withDefaultValue(new AddContactInfo(false, false))
/**
@ -72,12 +73,11 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
/**
* Displays newly connected devices in the list.
*/
override def onConnectionChanged(devices: Map[Device.ID, Device]): Unit = {
val filtered = devices.filter{ case (_, d) => d.Connected }
override def onNearbyContactsChanged(devices: Set[Address]): Unit = {
runOnUiThread(new Runnable {
override def run(): Unit = {
Adapter.clear()
filtered.values.foreach(f => Adapter.add(f))
devices.foreach(Adapter.add)
}
})
}
@ -86,34 +86,34 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
* Initiates adding the device as contact if it hasn't been added yet.
*/
override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit = {
val device = Adapter.getItem(position)
if (Database.isContact(device.Id)) {
val address = Adapter.getItem(position)
if (Database.isContact(address)) {
Toast.makeText(this, R.string.contact_already_added, Toast.LENGTH_SHORT).show()
return
}
service.send(new RequestAddContactMessage(service.localDeviceId, device.Id, new Date()))
addDeviceDialog(device)
service.send(new RequestAddContactMessage(Crypto.getLocalAddress, address, new Date()))
addDeviceDialog(address)
}
/**
* Shows a dialog to accept/deny adding a device as a new contact.
*/
private def addDeviceDialog(device: Device): Unit = {
val id = device.Id
private def addDeviceDialog(address: Address): Unit = {
// Listener for dialog button clicks.
val onClick = new OnClickListener {
override def onClick(dialogInterface: DialogInterface, i: Int): Unit = i match {
case DialogInterface.BUTTON_POSITIVE =>
// Local user accepted contact, update state and send info to other device.
currentlyAdding += (id -> new AddContactInfo(currentlyAdding(id).localConfirmed, true))
addContactIfBothConfirmed(device)
currentlyAdding +=
(address -> new AddContactInfo(currentlyAdding(address).localConfirmed, true))
addContactIfBothConfirmed(address)
service.send(
new ResultAddContactMessage(service.localDeviceId, device.Id, new Date(), true))
new ResultAddContactMessage(Crypto.getLocalAddress, address, new Date(), true))
case DialogInterface.BUTTON_NEGATIVE =>
// Local user denied adding contact, send info to other device.
service.send(
new ResultAddContactMessage(service.localDeviceId, device.Id, new Date(), false))
new ResultAddContactMessage(Crypto.getLocalAddress, address, new Date(), false))
}
}
@ -122,15 +122,14 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
val local = view.findViewById(R.id.local_identicon).asInstanceOf[ImageView]
local.setImageBitmap(
IdenticonGenerator.generate(Crypto.getLocalPublicKey, (150, 150), this))
IdenticonGenerator.generate(Crypto.getLocalAddress, (150, 150), this))
val remoteTitle = view.findViewById(R.id.remote_identicon_title).asInstanceOf[TextView]
remoteTitle.setText(getString(R.string.remote_fingerprint_title, device.Name))
remoteTitle.setText(getString(R.string.remote_fingerprint_title, address))
val remote = view.findViewById(R.id.remote_identicon).asInstanceOf[ImageView]
remote.setImageBitmap(
IdenticonGenerator.generate(Crypto.getPublicKey(device.Id), (150, 150), this))
remote.setImageBitmap(IdenticonGenerator.generate(address, (150, 150), this))
new AlertDialog.Builder(this)
.setTitle(getString(R.string.add_contact_dialog, device.Name))
.setTitle(getString(R.string.add_contact_dialog, address))
.setView(view)
.setPositiveButton(android.R.string.yes, onClick)
.setNegativeButton(android.R.string.no, onClick)
@ -144,18 +143,17 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
* the user is in this activity.
*/
override def onMessageReceived(messages: SortedSet[Message]): Unit = {
messages.filter(_.receiver == service.localDeviceId)
messages.filter(_.receiver == Crypto.getLocalAddress)
.foreach{
case m: RequestAddContactMessage =>
Log.i(Tag, "Remote device " + m.sender + " wants to add us as a contact, showing dialog")
val sender = getDevice(m.sender)
addDeviceDialog(sender)
addDeviceDialog(m.sender)
case m: ResultAddContactMessage =>
if (m.Accepted) {
Log.i(Tag, "Remote device " + m.sender + " accepted us as a contact, updating state")
currentlyAdding += (m.sender ->
new AddContactInfo(true, currentlyAdding(m.sender).remoteConfirmed))
addContactIfBothConfirmed(getDevice(m.sender))
addContactIfBothConfirmed(m.sender)
} else {
Log.i(Tag, "Remote device " + m.sender + " denied us as a contact, showing toast")
Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show()
@ -165,31 +163,18 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
}
}
/**
* Returns the [[Device]] for a given [[Device.ID]] that is stored in the [[Adapter]].
*/
private def getDevice(id: Device.ID): Device = {
// ArrayAdapter does not return the underlying array so we have to access it manually.
for (i <- 0 until Adapter.getCount) {
if (Adapter.getItem(i).Id == id) {
return Adapter.getItem(i)
}
}
throw new RuntimeException("Device to add was not found")
}
/**
* Add the given device to contacts if [[AddContactInfo.localConfirmed]] and
* [[AddContactInfo.remoteConfirmed]] are true for it in [[currentlyAdding]].
*/
private def addContactIfBothConfirmed(device: Device): Unit = {
val info = currentlyAdding(device.Id)
private def addContactIfBothConfirmed(address: Address): Unit = {
val info = currentlyAdding(address)
if (info.localConfirmed && info.remoteConfirmed) {
Log.i(Tag, "Adding new contact " + device.Name)
Database.addContact(device)
Toast.makeText(this, getString(R.string.contact_added, device.Name), Toast.LENGTH_SHORT)
Log.i(Tag, "Adding new contact " + address.toString)
Database.addContact(address)
Toast.makeText(this, getString(R.string.contact_added, address.toString), Toast.LENGTH_SHORT)
.show()
currentlyAdding -= device.Id
currentlyAdding -= address
}
}

View File

@ -7,7 +7,7 @@ import android.os.Bundle
import android.view.MenuItem
import android.widget.Toast
import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.fragments.{ChatFragment, ContactsFragment}
/**
@ -19,7 +19,7 @@ class MainActivity extends EnsiChatActivity {
private var ContactsFragment: ContactsFragment = _
private var currentChat: Option[Device.ID] = None
private var currentChat: Option[Address] = None
/**
* Initializes layout, starts service and requests Bluetooth to be discoverable.
@ -38,7 +38,7 @@ class MainActivity extends EnsiChatActivity {
ContactsFragment = fm.getFragment(savedInstanceState, classOf[ContactsFragment].getName)
.asInstanceOf[ContactsFragment]
if (savedInstanceState.containsKey("current_chat")) {
currentChat = Option(new Device.ID(savedInstanceState.getString("current_chat")))
currentChat = Option(new Address(savedInstanceState.getByteArray("current_chat")))
openChat(currentChat.get)
}
} else {
@ -55,7 +55,7 @@ class MainActivity extends EnsiChatActivity {
override def onSaveInstanceState(outState: Bundle): Unit = {
super.onSaveInstanceState(outState)
getFragmentManager.putFragment(outState, classOf[ContactsFragment].getName, ContactsFragment)
currentChat.collect{case c => outState.putString("current_chat", c.toString)}
currentChat.collect{case c => outState.putByteArray("current_chat", c.Bytes)}
}
/**
@ -74,12 +74,12 @@ class MainActivity extends EnsiChatActivity {
/**
* Opens a chat fragment for the given device, creating the fragment if needed.
*/
def openChat(device: Device.ID): Unit = {
currentChat = Some(device)
def openChat(address: Address): Unit = {
currentChat = Some(address)
getFragmentManager
.beginTransaction()
.detach(ContactsFragment)
.add(android.R.id.content, new ChatFragment(device))
.add(android.R.id.content, new ChatFragment(address))
.commit()
getActionBar.setDisplayHomeAsUpEnabled(true)
}

View File

@ -0,0 +1,43 @@
package com.nutomic.ensichat.aodvv2
import java.util
import com.google.common.io.BaseEncoding
object Address {
val Length = 32
// 32 bytes, all ones
// 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
val Broadcast = new Address("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")
// 32 bytes, all zeros
// 0x0000000000000000000000000000000000000000000000000000000000000000
val Null = new Address("0000000000000000000000000000000000000000000000000000000000000000")
}
/**
* Holds a device address and provides conversion methods.
*
* @param Bytes SHA-256 hash of the node's public key.
*/
class Address(val Bytes: Array[Byte]) {
require(Bytes.length == Address.Length, "Invalid address length (was " + Bytes.length + ")")
def this(base16: String) {
this(BaseEncoding.base16().decode(base16))
}
override def hashCode = util.Arrays.hashCode(Bytes)
override def equals(a: Any) = a match {
case o: Address => Bytes.deep == o.Bytes.deep
case _ => false
}
override def toString = BaseEncoding.base16().encode(Bytes)
}

View File

@ -0,0 +1,44 @@
package com.nutomic.ensichat.aodvv2
import java.nio.ByteBuffer
import java.security.spec.X509EncodedKeySpec
import java.security.{KeyFactory, PublicKey}
import com.nutomic.ensichat.messages.Crypto
import com.nutomic.ensichat.util.BufferUtils
object ConnectionInfo {
val Type = 0
val HopLimit = 1
/**
* Constructs [[ConnectionInfo]] instance from byte array.
*/
def read(array: Array[Byte]): ConnectionInfo = {
val b = ByteBuffer.wrap(array)
val length = BufferUtils.getUnsignedInt(b).toInt
val encoded = new Array[Byte](length)
b.get(encoded, 0, length)
val factory = KeyFactory.getInstance(Crypto.KeyAlgorithm)
val key = factory.generatePublic(new X509EncodedKeySpec(encoded))
new ConnectionInfo(key)
}
}
/**
* Holds a node's public key.
*/
class ConnectionInfo(val key: PublicKey) extends MessageBody {
override def write: Array[Byte] = {
val b = ByteBuffer.allocate(4 + key.getEncoded.length)
BufferUtils.putUnsignedInt(b, key.getEncoded.length)
b.put(key.getEncoded)
b.array()
}
}

View File

@ -0,0 +1,37 @@
package com.nutomic.ensichat.aodvv2
import java.nio.ByteBuffer
import com.nutomic.ensichat.util.BufferUtils
object Data {
val Type = 255
/**
* Constructs [[Data]] object from byte array.
*/
def read(array: Array[Byte]): Data = {
val b = ByteBuffer.wrap(array)
val length = BufferUtils.getUnsignedInt(b).toInt
val data = new Array[Byte](length)
b.get(data, 0, length)
new Data(data)
}
}
/**
* Container for [[com.nutomic.ensichat.messages.Message]] objects.
*/
@Deprecated
class Data(val data: Array[Byte]) extends MessageBody {
override def write: Array[Byte] = {
val b = ByteBuffer.allocate(4 + data.length)
BufferUtils.putUnsignedInt(b, data.length)
b.put(data)
b.array()
}
}

View File

@ -0,0 +1,14 @@
package com.nutomic.ensichat.aodvv2
/**
* Holds the actual message content.
*/
abstract class MessageBody {
/**
* Writes the message contents to a byte array.
* @return
*/
def write: Array[Byte]
}

View File

@ -0,0 +1,103 @@
package com.nutomic.ensichat.aodvv2
import java.nio.ByteBuffer
import java.util.Date
import com.nutomic.ensichat.util.BufferUtils
object MessageHeader {
val Length = 16 + 2 * Address.Length
val DefaultHopLimit = 20
val Version = 3
class ParseMessageException(detailMessage: String) extends RuntimeException(detailMessage) {
}
/**
* Constructs [[MessageHeader]] from byte array.
*/
def read(bytes: Array[Byte]): MessageHeader = {
val b = ByteBuffer.wrap(bytes, 0, Length)
val versionAndType = BufferUtils.getUnsignedShort(b)
val version = versionAndType >>> 12
if (version != Version)
throw new ParseMessageException("Failed to parse message with unsupported version " + version)
val messageType = versionAndType & 0xfff
val hopLimit = BufferUtils.getUnsignedByte(b)
val hopCount = BufferUtils.getUnsignedByte(b)
val length = BufferUtils.getUnsignedInt(b)
val time = new Date(b.getInt().toLong * 1000)
val origin = new Address(BufferUtils.getByteArray(b, Address.Length))
val target = new Address(BufferUtils.getByteArray(b, Address.Length))
val seqNum = BufferUtils.getUnsignedShort(b)
val metric = BufferUtils.getUnsignedByte(b)
new MessageHeader(messageType, hopLimit, time, origin, target, seqNum, metric, length, hopCount)
}
}
/**
* First part of any message, used for routing.
*/
class MessageHeader(val MessageType: Int,
val HopLimit: Int,
val Time: Date,
val Origin: Address,
val Target: Address,
val SequenceNumber: Int,
val Metric: Int,
val Length: Long = -1,
val HopCount: Int = 0) {
/**
* Writes the header to byte array.
*/
def write(body: MessageBody): Array[Byte] = {
val b = ByteBuffer.allocate(MessageHeader.Length)
val bodyBytes = body.write
val versionAndType = (MessageHeader.Version << 12) | MessageType
BufferUtils.putUnsignedShort(b, versionAndType)
BufferUtils.putUnsignedByte(b, HopLimit)
BufferUtils.putUnsignedByte(b, HopCount)
BufferUtils.putUnsignedInt(b, MessageHeader.Length + bodyBytes.length)
b.putInt((Time.getTime / 1000).toInt)
b.put(Origin.Bytes)
b.put(Target.Bytes)
BufferUtils.putUnsignedShort(b, SequenceNumber)
BufferUtils.putUnsignedByte(b, Metric)
BufferUtils.putUnsignedByte(b, 0)
b.array() ++ bodyBytes
}
override def equals(a: Any): Boolean = a match {
case o: MessageHeader =>
MessageType == o.MessageType &&
HopLimit == o.HopLimit &&
Time.getTime / 1000 == o.Time.getTime / 1000 &&
Origin == o.Origin &&
Target == o.Target &&
SequenceNumber == o.SequenceNumber &&
Metric == o.Metric &&
// Don't compare length as it may be unknown (when header was just created without a body).
//Length == o.Length &&
HopCount == o.HopCount
case _ => false
}
override def toString = "MessageHeader(Version=" + MessageHeader.Version +
", Type=" + MessageType + ", HopLimit=" + HopLimit + ", HopCount=" + HopCount +
", Time=" + Time + ", Origin=" + Origin + ", Target=" + Target + ", SeqNum=" +
", Metric=" + Metric + ", Length=" + Length + ", HopCount=" + HopCount + ")"
}

View File

@ -8,10 +8,13 @@ import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.os.Handler
import android.preference.PreferenceManager
import android.util.Log
import com.google.common.collect.HashBiMap
import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.ChatService.{OnConnectionChangedListener, OnMessageReceivedListener}
import com.nutomic.ensichat.aodvv2._
import com.nutomic.ensichat.bluetooth.ChatService.{OnMessageReceivedListener, OnNearbyContactsChangedListener}
import com.nutomic.ensichat.messages._
import com.nutomic.ensichat.util.Database
import org.msgpack.ScalaMessagePack
import scala.collection.SortedSet
import scala.collection.immutable.{HashMap, HashSet, TreeSet}
@ -26,8 +29,12 @@ object ChatService {
val KEY_GENERATION_FINISHED = "com.nutomic.ensichat.messages.KEY_GENERATION_FINISHED"
trait OnConnectionChangedListener {
def onConnectionChanged(devices: Map[Device.ID, Device]): Unit
/**
* Used with [[ChatService.registerConnectionListener]], called when a bluetooth device
* connects or disconnects
*/
trait OnNearbyContactsChangedListener {
def onNearbyContactsChanged(devices: Set[Address]): Unit
}
trait OnMessageReceivedListener {
@ -52,7 +59,7 @@ class ChatService extends Service {
* but on a Nexus S (Android 4.1.2), these functions are garbage collected even when
* referenced.
*/
private var connectionListeners = new HashSet[WeakReference[OnConnectionChangedListener]]()
private var connectionListeners = new HashSet[WeakReference[OnNearbyContactsChangedListener]]()
private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]()
@ -68,7 +75,9 @@ class ChatService extends Service {
private lazy val Database = new Database(this)
private lazy val Crypto = new Crypto(getFilesDir)
private lazy val Crypto = new Crypto(this)
private val AddressDeviceMap = HashBiMap.create[Address, Device.ID]()
/**
* Initializes BroadcastReceiver for discovery, starts discovery and listens for connections.
@ -90,7 +99,8 @@ class ChatService extends Service {
Crypto.generateLocalKeys()
}
}).start()
}
} else
Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress)
}
override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
@ -174,9 +184,9 @@ class ChatService extends Service {
/**
* Registers a listener that is called whenever a new device is connected.
*/
def registerConnectionListener(listener: OnConnectionChangedListener): Unit = {
connectionListeners += new WeakReference[OnConnectionChangedListener](listener)
listener.onConnectionChanged(devices)
def registerConnectionListener(listener: OnNearbyContactsChangedListener): Unit = {
connectionListeners += new WeakReference[OnNearbyContactsChangedListener](listener)
nearbyContactsChanged(listener)
}
/**
@ -192,25 +202,46 @@ class ChatService extends Service {
if (device.Connected) {
connections += (device.Id ->
new TransferThread(device, socket, this, Crypto, handleNewMessage))
new TransferThread(device, socket, this, Crypto, onReceiveMessage))
connections(device.Id).start()
connections.apply(device.Id).send(
new DeviceInfoMessage(localDeviceId, device.Id, new Date(), Crypto.getLocalPublicKey))
} else {
Log.i(Tag, device + " has disconnected")
AddressDeviceMap.inverse().remove(device.Id)
}
}
connectionListeners.foreach(l => l.get match {
case Some(x) => x.onConnectionChanged(devices)
case None => connectionListeners -= l
})
/**
* Calls listener with [[devices]] (converting [[Device.ID]]s to [[Address]]es.
*/
def nearbyContactsChanged(listener: OnNearbyContactsChangedListener) = {
listener.onNearbyContactsChanged(
devices.keySet.map(d => AddressDeviceMap.inverse().get(d)).filter(_ != null))
}
/**
* Sends message to the device specified as receiver,
*/
def send(message: Message): Unit = {
assert(message.sender == localDeviceId, "Message must be sent from local device")
connections.apply(message.receiver).send(message)
handleNewMessage(message)
assert(message.sender == Crypto.getLocalAddress, "Message must be sent from local device")
if (!AddressDeviceMap.containsKey(message.receiver)) {
Log.w(Tag, "Receiver " + message.receiver + " is not connected, ignoring message")
return
}
val header = new MessageHeader(Data.Type, MessageHeader.DefaultHopLimit,
new Date(), Crypto.getLocalAddress, message.receiver, 0, 0)
val plain = message.write(Crypto.calculateSignature(message))
val (encrypted, key) = Crypto.encrypt(message.receiver, plain)
val packer = new ScalaMessagePack().createBufferPacker()
packer
.write(encrypted)
.write(key)
val body = new Data(packer.toByteArray)
connections.apply(AddressDeviceMap.get(message.receiver)).send(header, body)
Database.addMessage(message)
}
/**
@ -219,21 +250,64 @@ class ChatService extends Service {
* If you want to send a new message, use [[send]].
*
* Messages must always be sent between local device and a contact.
*
* NOTE: Messages sent from the local node using [[send]] are also passed through this method.
*/
private def handleNewMessage(message: Message): Unit = {
assert(message.sender == localDeviceId || message.receiver == localDeviceId,
"Message must be sent or received by local device")
private def onReceiveMessage(header: MessageHeader, body: MessageBody, device: Device.ID): Unit = {
assert(header.Origin != Crypto.getLocalAddress)
Database.addMessage(message)
MainHandler.post(new Runnable {
override def run(): Unit = {
messageListeners.foreach(l =>
if (l.get != null)
l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message)
else
messageListeners -= l)
}
body match {
case info: ConnectionInfo =>
if (header.Origin == Crypto.getLocalAddress)
return
onNeighborConnected(info, device)
case data: Data =>
val up = new ScalaMessagePack().createBufferUnpacker(data.data)
val encrypted = up.readByteArray()
val key = up.readByteArray()
val (message, signature) = Message.read(Crypto.decrypt(encrypted, key))
if (!Crypto.isValidSignature(message, signature)) {
Log.i(Tag, "Dropping message with invalid signature from " + header.Origin)
return
}
Database.addMessage(message)
MainHandler.post(new Runnable {
override def run(): Unit = {
messageListeners.foreach(l =>
if (l.get != null)
l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message)
else
messageListeners -= l)
}
})
}
}
/**
* Called when a [[ConnectionInfo]] message from a new neighbor is received.
*/
private def onNeighborConnected(info: ConnectionInfo, device: Device.ID): Unit = {
val sender = Crypto.calculateAddress(info.key)
if (sender == Address.Broadcast || sender == Address.Null) {
connections(device).close()
return
}
if (!Crypto.havePublicKey(sender)) {
Crypto.addPublicKey(sender, info.key)
Log.i(Tag, "Added public key for new device " + sender.toString)
}
AddressDeviceMap.put(sender, device)
Log.i(Tag, "Node " + sender + " connected as " + device)
connectionListeners.foreach(l => l.get match {
case Some(c) => nearbyContactsChanged(c)
case None => connectionListeners -= l
})
}
/**
@ -246,10 +320,8 @@ class ChatService extends Service {
/**
* Returns the unique bluetooth address of the local device.
*/
def localDeviceId = new Device.ID(bluetoothAdapter.getAddress)
def isConnected(device: Device.ID): Boolean = connections.keySet.contains(device)
private def localDeviceId = new Device.ID(bluetoothAdapter.getAddress)
def database = Database
}
}

View File

@ -2,7 +2,7 @@ package com.nutomic.ensichat.bluetooth
import android.bluetooth.BluetoothDevice
object Device {
private[bluetooth] object Device {
/**
* Holds bluetooth device IDs, which are just wrapped addresses (used for type safety).
@ -28,7 +28,7 @@ object Device {
/**
* Holds information about a remote bluetooth device.
*/
class Device(val Id: Device.ID, val Name: String, val Connected: Boolean,
private[bluetooth] class Device(val Id: Device.ID, val Name: String, val Connected: Boolean,
btDevice: Option[BluetoothDevice] = None) {
def this(btDevice: BluetoothDevice, connected: Boolean) {
@ -37,4 +37,7 @@ class Device(val Id: Device.ID, val Name: String, val Connected: Boolean,
def bluetoothDevice = btDevice.get
override def toString = "Device(Id=" + Id + ", Name=" + Name + ", Connected=" + Connected +
", btDevice=" + btDevice + ")"
}

View File

@ -1,12 +1,12 @@
package com.nutomic.ensichat.bluetooth
import java.io._
import java.util.Date
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.nutomic.ensichat.messages.Message._
import com.nutomic.ensichat.messages.{Crypto, DeviceInfoMessage, Message}
import org.msgpack.ScalaMessagePack
import com.nutomic.ensichat.aodvv2._
import com.nutomic.ensichat.messages.Crypto
/**
* Transfers data between connnected devices.
@ -15,11 +15,11 @@ import org.msgpack.ScalaMessagePack
*
* @param device The bluetooth device to interact with.
* @param socket An open socket to the given device.
* @param crypto 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, service: ChatService,
crypto: Crypto, onReceive: (Message) => Unit) extends Thread {
crypto: Crypto, onReceive: (MessageHeader, MessageBody, Device.ID) => Unit)
extends Thread {
private val Tag: String = "TransferThread"
@ -44,81 +44,40 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
override def run(): Unit = {
Log.i(Tag, "Starting data transfer with " + device.toString)
send(new MessageHeader(ConnectionInfo.Type, ConnectionInfo.HopLimit, new Date(), Address.Null,
Address.Null, 0, 0), new ConnectionInfo(crypto.getLocalPublicKey))
while (socket.isConnected) {
try {
val up = new ScalaMessagePack().createUnpacker(InStream)
val isEncrypted = up.readBoolean()
val plain =
if (isEncrypted) {
val encrypted = up.readByteArray()
val key = up.readByteArray()
crypto.decrypt(encrypted, key)
} else {
up.readByteArray()
val headerBytes = new Array[Byte](MessageHeader.Length)
InStream.read(headerBytes, 0, MessageHeader.Length)
val header = MessageHeader.read(headerBytes)
val bodyLength = (header.Length - MessageHeader.Length).toInt
val bodyBytes = new Array[Byte](bodyLength)
InStream.read(bodyBytes, 0, bodyLength)
val body =
header.MessageType match {
case ConnectionInfo.Type => ConnectionInfo.read(bodyBytes)
case Data.Type => Data.read(bodyBytes)
}
val (message, signature) = Message.read(plain)
var messageValid = true
if (message.sender != device.Id) {
Log.i(Tag, "Dropping message with invalid sender from " + device.Id)
messageValid = false
}
if (message.receiver != service.localDeviceId) {
Log.i(Tag, "Dropping message with different receiver from " + device.Id)
messageValid = false
}
// Add public key for new, directly connected device.
// Explicitly check that message was not forwarded or spoofed.
if (message.isInstanceOf[DeviceInfoMessage] && !crypto.havePublicKey(message.sender) &&
message.sender == device.Id) {
val dim = message.asInstanceOf[DeviceInfoMessage]
// Permanently store public key for new local devices (also check signature).
if (crypto.isValidSignature(message, signature, dim.publicKey)) {
crypto.addPublicKey(device.Id, dim.publicKey)
Log.i(Tag, "Added public key for new device " + device.Name)
}
}
if (!crypto.isValidSignature(message, signature)) {
Log.i(Tag, "Dropping message with invalid signature from " + device.Id)
messageValid = false
}
if (messageValid) {
message match {
case m: DeviceInfoMessage => crypto.addPublicKey(message.sender, m.publicKey)
case _ => onReceive(message)
}
}
onReceive(header, body, device.Id)
} catch {
case e: IOException =>
Log.i(Tag, "Failed to read incoming message", e)
case e: RuntimeException =>
Log.i(Tag, "Received invalid message", e)
case e: IOException =>
Log.w(Tag, "Failed to read incoming message", e)
return
}
}
service.onConnectionChanged(new Device(device.bluetoothDevice, false), null)
}
def send(message: Message): Unit = {
def send(header: MessageHeader, body: MessageBody): Unit = {
try {
val plain = message.write(crypto.calculateSignature(message))
val packer = new ScalaMessagePack().createPacker(OutStream)
message.messageType match {
case Type.Text =>
val (encrypted, key) = crypto.encrypt(message.receiver, plain)
// Message is encrypted.
packer.write(true)
.write(encrypted)
.write(key)
case Type.DeviceInfo | Type.RequestAddContact | Type.ResultAddContact =>
// Message is not encrypted.
packer.write(false)
.write(plain)
}
OutStream.write(header.write(body))
} catch {
case e: IOException => Log.e(Tag, "Failed to write message", e)
}

View File

@ -11,9 +11,10 @@ import android.widget.TextView.OnEditorActionListener
import android.widget._
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.EnsiChatActivity
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.bluetooth.ChatService
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener
import com.nutomic.ensichat.bluetooth.{ChatService, Device}
import com.nutomic.ensichat.messages.{Message, TextMessage}
import com.nutomic.ensichat.messages.{Crypto, Message, TextMessage}
import com.nutomic.ensichat.util.MessagesAdapter
import scala.collection.SortedSet
@ -24,12 +25,15 @@ import scala.collection.SortedSet
class ChatFragment extends ListFragment with OnClickListener
with OnMessageReceivedListener {
def this(device: Device.ID) {
/**
* Fragments need to have a default constructor, so this is optional.
*/
def this(address: Address) {
this
this.device = device
this.address = address
}
private var device: Device.ID = _
private var address: Address = _
private var chatService: ChatService = _
@ -49,9 +53,9 @@ class ChatFragment extends ListFragment with OnClickListener
chatService = activity.service
// Read local device ID from service,
adapter = new MessagesAdapter(getActivity, chatService.localDeviceId)
adapter = new MessagesAdapter(getActivity, address)
chatService.registerMessageListener(ChatFragment.this)
onMessageReceived(chatService.database.getMessages(device, 15))
onMessageReceived(chatService.database.getMessages(address, 15))
if (listView != null) {
listView.setAdapter(adapter)
@ -83,13 +87,13 @@ class ChatFragment extends ListFragment with OnClickListener
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
device = new Device.ID(savedInstanceState.getString("device"))
address = new Address(savedInstanceState.getByteArray("device"))
}
}
override def onSaveInstanceState(outState: Bundle): Unit = {
super.onSaveInstanceState(outState)
outState.putString("device", device.toString)
outState.putByteArray("device", address.Bytes)
}
/**
@ -97,14 +101,12 @@ class ChatFragment extends ListFragment with OnClickListener
*/
override def onClick(view: View): Unit = view.getId match {
case R.id.send =>
val text: String = messageText.getText.toString.trim
val text = messageText.getText.toString.trim
if (!text.isEmpty) {
if (!chatService.isConnected(device)) {
Toast.makeText(getActivity, R.string.contact_offline_toast, Toast.LENGTH_SHORT).show()
return
}
chatService.send(
new TextMessage(chatService.localDeviceId, device, new Date(), text.toString))
val message =
new TextMessage(Crypto.getLocalAddress(getActivity), address, new Date(), text.toString)
chatService.send(message)
adapter.add(message)
messageText.getText.clear()
}
}
@ -113,7 +115,7 @@ class ChatFragment extends ListFragment with OnClickListener
* Displays new messages in UI.
*/
override def onMessageReceived(messages: SortedSet[Message]): Unit = {
messages.filter(m => m.sender == device || m.receiver == device)
messages.filter(m => m.sender == address || m.receiver == address)
.filter(_.isInstanceOf[TextMessage])
.foreach(m => adapter.add(m.asInstanceOf[TextMessage]))
}
@ -121,6 +123,6 @@ class ChatFragment extends ListFragment with OnClickListener
/**
* Returns the device that this fragment shows chats for.
*/
def getDevice = this.device
def getDevice = address
}

View File

@ -64,6 +64,6 @@ class ContactsFragment extends ListFragment {
* Opens a chat with the clicked device.
*/
override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit =
getActivity.asInstanceOf[MainActivity].openChat(Adapter.getItem(position).Id)
getActivity.asInstanceOf[MainActivity].openChat(Adapter.getItem(position))
}

View File

@ -6,8 +6,10 @@ import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
import javax.crypto.spec.SecretKeySpec
import javax.crypto.{Cipher, CipherOutputStream, KeyGenerator, SecretKey}
import android.content.Context
import android.preference.PreferenceManager
import android.util.Log
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages.Crypto._
import com.nutomic.ensichat.util.PRNGFixes
@ -38,16 +40,28 @@ object Crypto {
*/
val SymmetricKeyAlgorithm = "AES/CBC/PKCS5Padding"
/**
* Algorithm used to hash PublicKey and get the address.
*/
val KeyHashAlgorithm = "SHA-256"
private val LocalAddressKey = "local_address"
/**
* Returns the address of the local node.
*/
def getLocalAddress(context: Context) = new Address(
PreferenceManager.getDefaultSharedPreferences(context).getString(LocalAddressKey, null))
}
/**
* 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) {
class Crypto(Context: Context) {
private val Tag = "Crypto"
@ -61,27 +75,40 @@ class Crypto(filesDir: File) {
* 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()
var address: Address = null
var keyPair: KeyPair = null
do {
val keyGen = KeyPairGenerator.getInstance(KeyAlgorithm)
keyGen.initialize(KeySize)
keyPair = keyGen.genKeyPair()
address = calculateAddress(keyPair.getPublic)
// The hash must have at least one bit set to not collide with the broadcast address.
} while(address == Address.Broadcast || address == Address.Null)
PreferenceManager.getDefaultSharedPreferences(Context)
.edit()
.putString(Crypto.LocalAddressKey, address.toString)
.commit()
saveKey(PrivateKeyAlias, keyPair.getPrivate)
saveKey(PublicKeyAlias, keyPair.getPublic)
Log.i(Tag, "Generating cryptographic keys, address is " + address)
}
/**
* 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()
def havePublicKey(address: Address): Boolean = new File(keyFolder, address.toString).exists()
/**
* Returns the public key for the given device.
*
* @throws RuntimeException If the key does not exist.
*/
def getPublicKey(device: Device.ID): PublicKey = {
loadKey(device.toString, classOf[PublicKey])
def getPublicKey(address: Address): PublicKey = {
loadKey(address.toString, classOf[PublicKey])
}
/**
@ -89,14 +116,14 @@ class Crypto(filesDir: File) {
*
* If a key for the device already exists, nothing is done.
*
* @param device The device to wchi the key belongs.
* @param address The device to which 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)
def addPublicKey(address: Address, key: PublicKey): Unit = {
if (!havePublicKey(address)) {
saveKey(address.toString, key)
} else {
Log.i(Tag, "Already have key for " + device.toString + ", not overwriting")
Log.i(Tag, "Already have key for " + address.toString + ", not overwriting")
}
}
@ -210,7 +237,7 @@ class Crypto(filesDir: File) {
/**
* Returns the folder where keys are stored.
*/
private def keyFolder = new File(filesDir, "keys")
private def keyFolder = new File(Context.getFilesDir, "keys")
/**
* Encrypts data for the given receiver.
@ -220,7 +247,7 @@ class Crypto(filesDir: File) {
* @param key Optional RSA public key to use for encryption.
* @return Pair of AES encrypted data and RSA encrypted AES key.
*/
def encrypt(receiver: Device.ID, data: Array[Byte], key: PublicKey = null):
def encrypt(receiver: Address, data: Array[Byte], key: PublicKey = null):
(Array[Byte], Array[Byte]) = {
// Symmetric encryption of data
val secretKey = makeSecretKey()
@ -290,4 +317,15 @@ class Crypto(filesDir: File) {
new SecretKeySpec(key.getEncoded, SymmetricKeyAlgorithm)
}
/**
* Hashes the given public key and returns the hash as address.
*/
def calculateAddress(key: PublicKey): Address = {
val md = MessageDigest.getInstance(KeyHashAlgorithm)
val hash = md.digest(key.getEncoded)
new Address(hash)
}
def getLocalAddress = Crypto.getLocalAddress(Context)
}

View File

@ -1,43 +0,0 @@
package com.nutomic.ensichat.messages
import java.security.spec.X509EncodedKeySpec
import java.security.{KeyFactory, PublicKey}
import java.util.{Date, Objects}
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.toString == publicKey.toString
override def hashCode = Objects.hash(super.hashCode: java.lang.Integer, publicKey)
override def toString = "DeviceInfoMessage(" + sender.toString + ", " + receiver.toString +
", " + date.toString + ", " + publicKey.toString + ")"
}

View File

@ -3,7 +3,7 @@ package com.nutomic.ensichat.messages
import java.io.IOException
import java.util.{Date, Objects}
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import org.msgpack.ScalaMessagePack
import org.msgpack.packer.Packer
@ -16,9 +16,8 @@ object Message {
*/
object Type {
val Text = 1
val DeviceInfo = 2
val RequestAddContact = 3
val ResultAddContact = 4
val RequestAddContact = 2
val ResultAddContact = 3
}
/**
@ -40,13 +39,12 @@ object Message {
@throws[IOException]("If the message can't be parsed")
@throws[RuntimeException]("If the message has an unknown type")
val messageType = up.readInt()
val sender = new Device.ID(up.readString())
val receiver = new Device.ID(up.readString())
val sender = new Address(up.readByteArray())
val receiver = new Address(up.readByteArray())
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)
case Type.RequestAddContact => RequestAddContactMessage.read(sender, receiver, date, up)
case Type.ResultAddContact => ResultAddContactMessage.read(sender, receiver, date, up)
case t =>
@ -61,17 +59,18 @@ object Message {
*
* @param messageType One of [[Message.Type]].
*/
@Deprecated
abstract class Message(val messageType: Int) {
/**
* Device where the message was sent from.
*/
val sender: Device.ID
val sender: Address
/**
* Device the message is addressed to.
*/
val receiver: Device.ID
val receiver: Address
/**
* Timestamp of message creation.
@ -86,8 +85,8 @@ abstract class Message(val messageType: Int) {
def write(signature: Array[Byte]): Array[Byte] = {
val packer = new ScalaMessagePack().createBufferPacker()
packer.write(messageType)
.write(sender.toString)
.write(receiver.toString)
.write(sender.Bytes)
.write(receiver.Bytes)
.write(date.getTime)
.write(signature)
doWrite(packer)

View File

@ -3,14 +3,14 @@ package com.nutomic.ensichat.messages
import java.util.Date
import com.nutomic.ensichat.activities.AddContactsActivity
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages.Message._
import org.msgpack.packer.Packer
import org.msgpack.unpacker.Unpacker
object RequestAddContactMessage {
def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker) =
def read(sender: Address, receiver: Address, date: Date, up: Unpacker) =
new RequestAddContactMessage(sender, receiver, date)
}
@ -18,7 +18,7 @@ object RequestAddContactMessage {
/**
* Message sent by [[AddContactsActivity]] to notify a device that it should be added as a contact.
*/
class RequestAddContactMessage(override val sender: Device.ID, override val receiver: Device.ID,
class RequestAddContactMessage(override val sender: Address, override val receiver: Address,
override val date: Date) extends Message(Type.RequestAddContact) {
override def doWrite(packer: Packer) = {

View File

@ -3,14 +3,14 @@ package com.nutomic.ensichat.messages
import java.util.{Date, Objects}
import com.nutomic.ensichat.activities.AddContactsActivity
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages.Message._
import org.msgpack.packer.Packer
import org.msgpack.unpacker.Unpacker
object ResultAddContactMessage {
def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker) =
def read(sender: Address, receiver: Address, date: Date, up: Unpacker) =
new ResultAddContactMessage(sender, receiver, date, up.readBoolean())
}
@ -19,7 +19,7 @@ object ResultAddContactMessage {
* Message sent by [[AddContactsActivity]] to tell a device whether the user confirmed adding it
* to contacts.
*/
class ResultAddContactMessage(override val sender: Device.ID, override val receiver: Device.ID,
class ResultAddContactMessage(override val sender: Address, override val receiver: Address,
override val date: Date, val Accepted: Boolean)
extends Message(Type.ResultAddContact) {

View File

@ -2,14 +2,14 @@ package com.nutomic.ensichat.messages
import java.util.{Date, Objects}
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages.Message._
import org.msgpack.packer.Packer
import org.msgpack.unpacker.Unpacker
object TextMessage {
def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker): TextMessage =
def read(sender: Address, receiver: Address, date: Date, up: Unpacker): TextMessage =
new TextMessage(sender, receiver, date, up.readString())
}
@ -17,7 +17,7 @@ object TextMessage {
/**
* Message that contains text.
*/
class TextMessage(override val sender: Device.ID, override val receiver: Device.ID,
class TextMessage(override val sender: Address, override val receiver: Address,
override val date: Date, val text: String) extends Message(Type.Text) {
override def doWrite(packer: Packer) = packer.write(text)

View File

@ -0,0 +1,30 @@
package com.nutomic.ensichat.util
import java.nio.ByteBuffer
object BufferUtils {
def getUnsignedByte(bb: ByteBuffer): Short = (bb.get & 0xff).toShort
def putUnsignedByte(bb: ByteBuffer, value: Int) = bb.put((value & 0xff).toByte)
def getUnsignedShort(bb: ByteBuffer): Int = bb.getShort & 0xffff
def putUnsignedShort(bb: ByteBuffer, value: Int) = bb.putShort((value & 0xffff).toShort)
def getUnsignedInt(bb: ByteBuffer): Long = bb.getInt.toLong & 0xffffffffL
def putUnsignedInt(bb: ByteBuffer, value: Long) = bb.putInt((value & 0xffffffffL).toInt)
/**
* Reads a byte array with the given length and returns it.
*/
def getByteArray(bb: ByteBuffer, numBytes: Int): Array[Byte] = {
val b = new Array[Byte](numBytes)
bb.get(b, 0, numBytes)
b
}
def toString(bb: ByteBuffer)= bb.array().slice(0, 4).map("%02X" format _).mkString
}

View File

@ -4,7 +4,7 @@ import java.util.Date
import android.content.{ContentValues, Context}
import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper}
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages._
import scala.collection.SortedSet
@ -18,15 +18,14 @@ object Database {
private val CreateMessagesTable = "CREATE TABLE messages(" +
"_id integer primary key autoincrement," +
"sender string not null," +
"receiver string not null," +
"text blob not null," +
"sender text not null," +
"receiver text not null," +
"text text not null," +
"date integer not null);" // Unix timestamp of message.
private val CreateContactsTable = "CREATE TABLE contacts(" +
"_id integer primary key autoincrement," +
"device_id string not null," +
"name string not null)"
"address text not null)"
}
@ -48,16 +47,16 @@ class Database(context: Context) extends SQLiteOpenHelper(context, Database.Data
/**
* Returns the count last messages for device.
*/
def getMessages(device: Device.ID, count: Int): SortedSet[Message] = {
def getMessages(address: Address, count: Int): SortedSet[Message] = {
val c = getReadableDatabase.query(true,
"messages", Array("sender", "receiver", "text", "date"),
"sender = ? OR receiver = ?", Array(device.toString, device.toString),
"sender = ? OR receiver = ?", Array(address.toString, address.toString),
null, null, "date DESC", count.toString)
var messages = new TreeSet[Message]()(Message.Ordering)
while (c.moveToNext()) {
val m = new TextMessage(
new Device.ID(c.getString(c.getColumnIndex("sender"))),
new Device.ID(c.getString(c.getColumnIndex("receiver"))),
new Address(c.getString(c.getColumnIndex("sender"))),
new Address(c.getString(c.getColumnIndex("receiver"))),
new Date(c.getLong(c.getColumnIndex("date"))),
new String(c.getString(c.getColumnIndex ("text"))))
messages += m
@ -78,20 +77,19 @@ class Database(context: Context) extends SQLiteOpenHelper(context, Database.Data
cv.put("date", msg.date.getTime.toString)
cv.put("text", msg.text)
getWritableDatabase.insert("messages", null, cv)
case _: DeviceInfoMessage | _: RequestAddContactMessage | _: ResultAddContactMessage =>
case _: RequestAddContactMessage | _: ResultAddContactMessage =>
// Never stored.
}
/**
* Returns a list of all contacts of this device.
*/
def getContacts: Set[Device] = {
val c = getReadableDatabase.query(true, "contacts", Array("device_id", "name"), "", Array(),
null, null, "name DESC", null)
var contacts = Set[Device]()
def getContacts: Set[Address] = {
val c = getReadableDatabase.query(true, "contacts", Array("address"), "", Array(),
null, null, null, null)
var contacts = Set[Address]()
while (c.moveToNext()) {
contacts += new Device(new Device.ID(c.getString(c.getColumnIndex("device_id"))),
c.getString(c.getColumnIndex("name")), false)
contacts += new Address(c.getString(c.getColumnIndex("address")))
}
c.close()
contacts
@ -100,19 +98,18 @@ class Database(context: Context) extends SQLiteOpenHelper(context, Database.Data
/**
* Returns true if a contact with the given device ID exists.
*/
def isContact(device: Device.ID): Boolean = {
val c = getReadableDatabase.query(true, "contacts", Array("_id"), "device_id = ?",
Array(device.toString), null, null, null, null)
def isContact(address: Address): Boolean = {
val c = getReadableDatabase.query(true, "contacts", Array("_id"), "address = ?",
Array(address.toString), null, null, null, null)
c.getCount != 0
}
/**
* Inserts the given device into contacts.
*/
def addContact(device: Device): Unit = {
def addContact(address: Address): Unit = {
val cv = new ContentValues()
cv.put("device_id", device.Id.toString)
cv.put("name", device.Name)
cv.put("address", address.toString)
getWritableDatabase.insert("contacts", null, cv)
contactsUpdatedListeners.foreach(_())
}

View File

@ -3,18 +3,19 @@ package com.nutomic.ensichat.util
import android.content.Context
import android.view.{View, ViewGroup}
import android.widget.{ArrayAdapter, TextView}
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.bluetooth.Device
/**
* Displays [[Device]]s in ListView.
*/
class DevicesAdapter(context: Context) extends
ArrayAdapter[Device](context, android.R.layout.simple_list_item_1) {
ArrayAdapter[Address](context, android.R.layout.simple_list_item_1) {
override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
val view = super.getView(position, convertView, parent)
val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView]
title.setText(getItem(position).Name)
title.setText(getItem(position).toString)
view
}

View File

@ -1,10 +1,9 @@
package com.nutomic.ensichat.util
import java.security.{MessageDigest, PublicKey}
import android.content.Context
import android.graphics.Bitmap.Config
import android.graphics.{Bitmap, Canvas, Color}
import com.nutomic.ensichat.aodvv2.Address
/**
* Calculates a unique identicon for the given hash.
@ -25,10 +24,8 @@ object IdenticonGenerator {
*
* @param size The size of the bitmap returned.
*/
def generate(key: PublicKey, size: (Int, Int), context: Context): Bitmap = {
// Hash the key.
val digest = MessageDigest.getInstance("SHA-1")
val hash = digest.digest(key.getEncoded)
def generate(address: Address, size: (Int, Int), context: Context): Bitmap = {
val hash = address.Bytes
// Create base image and colors.
var identicon = Bitmap.createBitmap(Width, Height, Config.ARGB_8888)

View File

@ -4,13 +4,13 @@ import android.content.Context
import android.view.{Gravity, View, ViewGroup}
import android.widget.{ArrayAdapter, RelativeLayout, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.aodvv2.Address
import com.nutomic.ensichat.messages.TextMessage
/**
* Displays [[TextMessage]]s in ListView.
*/
class MessagesAdapter(context: Context, localDevice: Device.ID) extends
class MessagesAdapter(context: Context, remoteAddress: Address) extends
ArrayAdapter[TextMessage](context, R.layout.item_message, android.R.id.text1) {
/**
@ -26,7 +26,7 @@ class MessagesAdapter(context: Context, localDevice: Device.ID) extends
val lp = new RelativeLayout.LayoutParams(tv.getLayoutParams)
val margin = (MessageMargin * context.getResources.getDisplayMetrics.density).toInt
if (getItem(position).sender == localDevice) {
if (getItem(position).sender != remoteAddress) {
view.setGravity(Gravity.RIGHT)
lp.setMargins(margin, 0, 0, 0)
} else {