Compare commits

...

4 Commits

13 changed files with 154 additions and 92 deletions

View File

@ -9,6 +9,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
@ -93,9 +94,11 @@ class ChatActivity : ServiceBoundActivity() {
chatAdapter.clear()
val contact = airaService.contacts[sessionId]
val avatar = if (contact == null) {
displayIconTrustLevel(false, false)
sessionName = airaService.savedNames[sessionId]
airaService.savedAvatars[sessionId]
} else {
displayIconTrustLevel(true, contact.verified)
sessionName = contact.name
contact.avatar
}
@ -110,7 +113,6 @@ class ChatActivity : ServiceBoundActivity() {
}
}
if (contact != null) {
displayIconTrustLevel(true, contact.verified)
loadMsgs(contact.uuid)
}
airaService.savedMsgs[sessionId]?.let {
@ -128,6 +130,7 @@ class ChatActivity : ServiceBoundActivity() {
binding.bottomPanel.visibility = View.VISIBLE
}
airaService.uiCallbacks = object : AIRAService.UiCallbacks {
override fun onConnectFailed(ip: String, errorMsg: String?) {}
override fun onNewSession(sessionId: Int, ip: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
@ -206,15 +209,28 @@ class ChatActivity : ServiceBoundActivity() {
}
private fun displayIconTrustLevel(isContact: Boolean, isVerified: Boolean) {
val setResource = fun (imageView: ImageView, resource: Int?) {
imageView.apply {
visibility = if (resource == null) {
View.GONE
} else {
setImageResource(resource)
View.VISIBLE
}
}
}
when {
isVerified -> {
binding.imageTrustLevel.setImageResource(R.drawable.ic_verified)
setResource(binding.toolbar.toolbarImageTrustLevel, R.drawable.ic_verified)
setResource(binding.bottomImageTrustLevel, R.drawable.ic_verified)
}
isContact -> {
binding.imageTrustLevel.setImageDrawable(null)
setResource(binding.toolbar.toolbarImageTrustLevel, null)
setResource(binding.bottomImageTrustLevel, null)
}
else -> {
binding.imageTrustLevel.setImageResource(R.drawable.ic_warning)
setResource(binding.toolbar.toolbarImageTrustLevel, R.drawable.ic_warning)
setResource(binding.bottomImageTrustLevel, R.drawable.ic_warning)
}
}
}

View File

@ -39,6 +39,15 @@ class MainActivity : ServiceBoundActivity() {
}
}
private val uiCallbacks = object : AIRAService.UiCallbacks {
override fun onConnectFailed(ip: String, errorMsg: String?) {
var msg = getString(R.string.unable_to_connect_to, ip)
errorMsg?.let {
msg += ": $it"
}
runOnUiThread {
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show()
}
}
override fun onNewSession(sessionId: Int, ip: String) {
runOnUiThread {
handleNewSession(sessionId, ip)

View File

@ -22,6 +22,7 @@ import com.bumptech.glide.request.transition.Transition
import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.databinding.ActivitySettingsBinding
import sushi.hardcore.aira.databinding.ChangeAvatarDialogBinding
import sushi.hardcore.aira.databinding.DialogEditTextBinding
import sushi.hardcore.aira.utils.AvatarPicker
import sushi.hardcore.aira.utils.StringUtils
@ -46,9 +47,7 @@ class SettingsActivity: AppCompatActivity() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
findPreference<Preference>("identityAvatar")?.let { identityAvatarPreference = it }
val identityNamePreference = findPreference<EditTextPreference>("identityName")
val paddingPreference = findPreference<SwitchPreferenceCompat>("psecPadding")
identityNamePreference?.isPersistent = false
paddingPreference?.isPersistent = false
AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(activity))?.let { avatar ->
displayAvatar(avatar)
@ -58,7 +57,6 @@ class SettingsActivity: AppCompatActivity() {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
identityNamePreference?.text = airaService.identityName
paddingPreference?.isChecked = airaService.usePadding
}
override fun onServiceDisconnected(name: ComponentName?) {}
@ -84,10 +82,17 @@ class SettingsActivity: AppCompatActivity() {
dialogBuilder.setView(dialogBinding.root).show()
false
}
identityNamePreference?.setOnPreferenceChangeListener { _, newValue ->
if (airaService.changeName(newValue as String)) {
identityNamePreference.text = newValue
}
findPreference<Preference>("identityName")?.setOnPreferenceClickListener {
val dialogBinding = DialogEditTextBinding.inflate(layoutInflater)
dialogBinding.editText.setText(airaService.identityName)
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setTitle(it.title)
.setView(dialogBinding.root)
.setPositiveButton(R.string.ok) { _, _ ->
airaService.changeName(dialogBinding.editText.text.toString())
}
.setNegativeButton(R.string.cancel, null)
.show()
false
}
findPreference<Preference>("deleteIdentity")?.setOnPreferenceClickListener {

View File

@ -103,6 +103,7 @@ class AIRAService : Service() {
}
interface UiCallbacks {
fun onConnectFailed(ip: String, errorMsg: String?)
fun onNewSession(sessionId: Int, ip: String)
fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String)
@ -456,13 +457,20 @@ class AIRAService : Service() {
MESSAGE_CONNECT_TO -> {
msg.data.getString("ip")?.let { ip ->
Thread {
try {
val socket = SocketChannel.open()
if (socket.connect(InetSocketAddress(ip, Constants.port))) {
handleNewSocket(socket, true)
val addr = InetSocketAddress(ip, Constants.port)
if (addr.isUnresolved) {
uiCallbacks?.onConnectFailed(ip, getString(R.string.invalid_ip))
} else {
try {
val socket = SocketChannel.open()
if (socket.connect(addr)) {
handleNewSocket(socket, true)
}
} catch (e: NoRouteToHostException) {
uiCallbacks?.onConnectFailed(ip, e.message)
} catch (e: ConnectException) {
uiCallbacks?.onConnectFailed(ip, e.message)
}
} catch (e: ConnectException) {
Log.w("Connect failed", "$ip: "+e.message)
}
}.start()
}

View File

@ -8,6 +8,7 @@ import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
import org.whispersystems.curve25519.Curve25519
import sushi.hardcore.aira.AIRADatabase
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.channels.*
import java.nio.channels.spi.SelectorProvider
@ -42,8 +43,6 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
}
private val prng = SecureRandom()
private val handshakeSentBuff = ByteArrayOutputStream(handshakeBufferLen)
private val handshakeRecvBuff = ByteArrayOutputStream(handshakeBufferLen)
private val peerCipher = Cipher.getInstance(CIPHER_TYPE)
private val localCipher = Cipher.getInstance(CIPHER_TYPE)
private var peerCounter = 0L
@ -52,42 +51,37 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
lateinit var peerPublicKey: ByteArray
val ip: String = socket.socket().inetAddress.hostAddress
private fun handshakeWrite(buffer: ByteArray) {
private fun handshakeWrite(buffer: ByteArray, handshakeSentBuff: OutputStream) {
writeAll(buffer)
handshakeSentBuff.write(buffer)
}
private fun handshakeRead(buffer: ByteBuffer): Boolean {
return if (socket.read(buffer) == buffer.position()) {
private fun handshakeRead(buffer: ByteBuffer, handshakeRecvBuff: OutputStream): Boolean {
return if (readAll(buffer)) {
handshakeRecvBuff.write(buffer.array())
true
} else {
false
}
}
private fun handshakeRead(buffer: ByteArray): Boolean {
return handshakeRead(ByteBuffer.wrap(buffer))
}
private fun hashHandshake(iAmBob: Boolean): ByteArray {
private fun hashHandshake(iAmBob: Boolean, handshakeSentBuff: ByteArray, handshakeRecvBuff: ByteArray): ByteArray {
MessageDigest.getInstance("SHA-384").apply {
if (iAmBob) {
update(handshakeSentBuff.toByteArray())
update(handshakeRecvBuff.toByteArray())
update(handshakeSentBuff)
update(handshakeRecvBuff)
} else {
update(handshakeRecvBuff.toByteArray())
update(handshakeSentBuff.toByteArray())
update(handshakeRecvBuff)
update(handshakeSentBuff)
}
return digest()
}
}
private fun amIBob(): Boolean {
val s = handshakeSentBuff.toByteArray()
val r = handshakeRecvBuff.toByteArray()
for (i in s.indices) {
if (s[i] != r[i]) {
return s[i].toInt() and 0xff < r[i].toInt() and 0xff
private fun amIBob(handshakeSentBuff: ByteArray, handshakeRecvBuff: ByteArray): Boolean {
for (i in handshakeSentBuff.indices) {
if (handshakeSentBuff[i] != handshakeRecvBuff[i]) {
return handshakeSentBuff[i].toInt() and 0xff < handshakeRecvBuff[i].toInt() and 0xff
}
}
throw SecurityException("Handshake buffers are identical")
@ -102,64 +96,62 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
}
fun doHandshake(): Boolean {
val handshakeSentBuff = ByteArrayOutputStream(handshakeBufferLen)
val handshakeRecvBuff = ByteArrayOutputStream(handshakeBufferLen)
val randomBuffer = ByteArray(RANDOM_LEN)
prng.nextBytes(randomBuffer)
val curve25519Cipher = Curve25519.getInstance(Curve25519.BEST)
val keypair = curve25519Cipher.generateKeyPair()
handshakeWrite(randomBuffer+keypair.publicKey)
handshakeWrite(randomBuffer+keypair.publicKey, handshakeSentBuff)
var recvBuffer = ByteBuffer.allocate(RANDOM_LEN+PUBLIC_KEY_LEN)
if (handshakeRead(recvBuffer)) {
if (handshakeRead(recvBuffer, handshakeRecvBuff)) {
val peerEphemeralPublicKey = recvBuffer.array().sliceArray(RANDOM_LEN until recvBuffer.capacity())
val sharedSecret = curve25519Cipher.calculateAgreement(peerEphemeralPublicKey, keypair.privateKey)
val iAmBob = amIBob() //mutual consensus for keys attribution
var handshakeHash = hashHandshake(iAmBob)
val iAmBob = amIBob(handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray()) //mutual consensus for keys attribution
var handshakeHash = hashHandshake(iAmBob, handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray())
val handshakeKeys = deriveHandshakeKeys(sharedSecret, handshakeHash, iAmBob)
prng.nextBytes(randomBuffer)
handshakeWrite(randomBuffer)
if (handshakeRead(randomBuffer)) {
val localCipher = Cipher.getInstance(CIPHER_TYPE)
localCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(handshakeKeys.localKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, ivToNonce(handshakeKeys.localIv, 0)))
handshakeWrite(localCipher.doFinal(AIRADatabase.getIdentityPublicKey()+sign(keypair.publicKey)))
val localCipher = Cipher.getInstance(CIPHER_TYPE)
localCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(handshakeKeys.localKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, handshakeKeys.localIv))
handshakeWrite(localCipher.doFinal(randomBuffer+AIRADatabase.getIdentityPublicKey()+sign(keypair.publicKey)), handshakeSentBuff)
recvBuffer = ByteBuffer.allocate(PUBLIC_KEY_LEN+SIGNATURE_LEN+AES_TAG_LEN)
if (handshakeRead(recvBuffer)) {
val peerCipher = Cipher.getInstance(CIPHER_TYPE)
peerCipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(handshakeKeys.peerKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, ivToNonce(handshakeKeys.peerIv, 0)))
val plainText: ByteArray
try {
plainText = peerCipher.doFinal(recvBuffer.array())
} catch (e: BadPaddingException) {
Log.w("BadPaddingException", ip)
return false
} catch (e: AEADBadTagException) {
Log.w("AEADBadTagException", ip)
return false
}
peerPublicKey = plainText.sliceArray(0 until PUBLIC_KEY_LEN)
val signature = plainText.sliceArray(PUBLIC_KEY_LEN until plainText.size)
recvBuffer = ByteBuffer.allocate(RANDOM_LEN+PUBLIC_KEY_LEN+SIGNATURE_LEN+AES_TAG_LEN)
if (handshakeRead(recvBuffer, handshakeRecvBuff)) {
val peerCipher = Cipher.getInstance(CIPHER_TYPE)
peerCipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(handshakeKeys.peerKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, handshakeKeys.peerIv))
val plainText: ByteArray
try {
plainText = peerCipher.doFinal(recvBuffer.array())
} catch (e: BadPaddingException) {
Log.w("BadPaddingException", ip)
return false
} catch (e: AEADBadTagException) {
Log.w("AEADBadTagException", ip)
return false
}
peerPublicKey = plainText.sliceArray(RANDOM_LEN until RANDOM_LEN+PUBLIC_KEY_LEN)
val signature = plainText.sliceArray(RANDOM_LEN+PUBLIC_KEY_LEN until plainText.size)
val edDSAEngine = EdDSAEngine().apply {
initVerify(EdDSAPublicKey(EdDSAPublicKeySpec(peerPublicKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)))
}
if (edDSAEngine.verifyOneShot(peerEphemeralPublicKey, signature)) {
handshakeHash = hashHandshake(iAmBob)
val handshakeFinished = computeHandshakeFinished(handshakeKeys.localHandshakeTrafficSecret, handshakeHash)
writeAll(handshakeFinished)
val peerHandshakeFinished = ByteBuffer.allocate(HASH_OUTPUT_LEN)
socket.read(peerHandshakeFinished)
if (verifyHandshakeFinished(peerHandshakeFinished.array(), handshakeKeys.peerHandshakeTrafficSecret, handshakeHash)){
applicationKeys = deriveApplicationKeys(handshakeKeys.handshakeSecret, handshakeHash, iAmBob)
handshakeSentBuff.reset()
handshakeRecvBuff.reset()
return true
} else {
Log.w("Handshake", "Final verification failed")
}
val edDSAEngine = EdDSAEngine().apply {
initVerify(EdDSAPublicKey(EdDSAPublicKeySpec(peerPublicKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)))
}
if (edDSAEngine.verifyOneShot(peerEphemeralPublicKey, signature)) {
handshakeHash = hashHandshake(iAmBob, handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray())
val handshakeFinished = computeHandshakeFinished(handshakeKeys.localHandshakeTrafficSecret, handshakeHash)
writeAll(handshakeFinished)
val peerHandshakeFinished = ByteBuffer.allocate(HASH_OUTPUT_LEN)
socket.read(peerHandshakeFinished)
if (verifyHandshakeFinished(peerHandshakeFinished.array(), handshakeKeys.peerHandshakeTrafficSecret, handshakeHash)){
applicationKeys = deriveApplicationKeys(handshakeKeys.handshakeSecret, handshakeHash, iAmBob)
return true
} else {
Log.w("Handshake", "Signature verification failed")
Log.w("Handshake", "Final verification failed")
}
} else {
Log.w("Handshake", "Signature verification failed")
}
}
}

View File

@ -34,14 +34,14 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/image_trust_level"/>
app:layout_constraintEnd_toStartOf="@id/bottom_image_trust_level"/>
<ImageView
android:id="@+id/image_trust_level"
android:id="@+id/bottom_image_trust_level"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_warning"
android:contentDescription="@string/trust_level_indicator"
android:layout_marginEnd="5dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/button_attach"
@ -53,10 +53,11 @@
android:layout_height="wrap_content"
android:hint="@string/message_hint"
android:autofillHints="message"
android:inputType="text"
style="@style/EditText"
android:inputType="textShortMessage|textAutoCorrect|textCapSentences"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/image_trust_level"
app:layout_constraintStart_toEndOf="@id/bottom_image_trust_level"
app:layout_constraintEnd_toStartOf="@+id/button_send"/>
<ImageButton

View File

@ -69,6 +69,7 @@
android:imeOptions="actionGo"
android:hint="@string/add_peer_ip"
android:autofillHints="ip"
style="@style/EditText"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="30dp"
android:autofillHints="name"
android:hint="@string/name"
android:inputType="textPersonName"/>
</RelativeLayout>

View File

@ -25,8 +25,16 @@
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
android:layout_marginStart="10dp"
android:layout_marginEnd="5dp"
android:layout_gravity="center_vertical"/>
<ImageView
android:id="@+id/toolbar_image_trust_level"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_gravity="center_vertical"
android:visibility="gone"/>
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.Toolbar>

View File

@ -3,10 +3,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/delete_conversation"
android:id="@+id/session_info"
app:showAsAction="ifRoom"
android:icon="@drawable/ic_delete_conversation"
android:title="@string/delete_conversation" />
android:icon="@drawable/ic_info"
android:title="@string/details"/>
<item
android:id="@+id/verify"
@ -27,10 +27,10 @@
android:title="@string/remove_contact"/>
<item
android:id="@+id/session_info"
android:id="@+id/delete_conversation"
app:showAsAction="ifRoom"
android:icon="@drawable/ic_info"
android:title="@string/details"/>
android:icon="@drawable/ic_delete_conversation"
android:title="@string/delete_conversation" />
<item
android:id="@+id/refresh_profile"

View File

@ -94,6 +94,8 @@
<string name="choose_avatar">Choose avatar</string>
<string name="message_hint">Send a message…</string>
<string name="no_name_error">This session has no name !</string>
<string name="invalid_ip">Invalid IP address</string>
<string name="unable_to_connect_to">Unable to connect to %s</string>
<!--accessibility strings-->
<string name="send_file">Send file</string>
@ -102,4 +104,5 @@
<string name="show_your_ips">Show your IPs</string>
<string name="clickable_indicator">Clickable indicator</string>
<string name="avatar">Avatar</string>
</resources>
<string name="name">Name</string>
</resources>

View File

@ -18,6 +18,10 @@
<item name="android:scaleType">fitXY</item>
<item name="android:layout_margin">5dp</item>
</style>
<style name="EditText" parent="Widget.AppCompat.EditText">
<item name="android:background">@color/transparent</item>
<item name="android:textSize">16sp</item>
</style>
<style name="Theme.AIRA.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>

View File

@ -9,7 +9,7 @@
android:summary="The avatar of your identity. Shown to all active sessions."
android:icon="@drawable/ic_face"/>
<EditTextPreference
<Preference
android:key="identityName"
android:title="@string/identity_name"
android:summary="@string/summary_name"