Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b220187
implement mutual TLS and two-step verification for P2P connections
ahlem-jarrar Jun 13, 2026
7934229
feat(p2p): Prevent premature sender identity reset in manual connection
ahlem-jarrar Jun 13, 2026
39a51f7
feat(p2p): Streamline manual connection and standardize sender hash d…
ahlem-jarrar Jun 13, 2026
c0c0e41
feat(p2p): Align Nearby Sharing hash verification screens with design
ahlem-jarrar Jun 15, 2026
023845f
feat(p2p): Improve client network selection and registration timeouts
ahlem-jarrar Jun 15, 2026
ee4cfb1
feat(p2p): Introduce receiver and sender hash confirmation states and…
ahlem-jarrar Jun 15, 2026
3a0d3f1
feat(p2p): Implement recipient hash verification and update P2P serve…
ahlem-jarrar Jun 15, 2026
d675474
refactor(p2p): Refine sender hash verification UI feedback
ahlem-jarrar Jun 15, 2026
3ba3e29
feat(p2p): Implement senderShowHash flag in manual P2P ping
ahlem-jarrar Jun 16, 2026
df29d0e
feat(p2p): Update sender QR screen button and layout
ahlem-jarrar Jun 16, 2026
6573a61
feat(p2p): Implement held manual ping for immediate receiver hash ver…
ahlem-jarrar Jun 18, 2026
7474691
feat(p2p): Enhance P2P server API version and route handling
ahlem-jarrar Jun 18, 2026
ffa715a
refactor: Clear stale P2P events and rename disabled button drawable
ahlem-jarrar Jun 18, 2026
9cb6aed
refactor: more ui improvements
ahlem-jarrar Jun 18, 2026
b9dbfbd
Consolidate verification confirm button state
ahlem-jarrar Jun 18, 2026
82b7de6
Ensure single server setup in recipient flow
ahlem-jarrar Jun 18, 2026
4413c53
Fix hash verification screen implementation missmatch
ahlem-jarrar Jun 22, 2026
eb0b132
Fix T-iOS-780 - iOS Sender to Android Receiver (full manual) not wor…
ahlem-jarrar Jun 22, 2026
c9dfdf7
bump version code
ahlem-jarrar Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions mobile/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ android {
minSdk versions.minSdk
targetSdk versions.targetSdk
// F-Droid fastlane changelog: fastlane/metadata/android/en-US/changelogs/<versionCode>.txt
versionCode 242
versionName "3.1.0"
versionCode 244
versionName "3.2.0"
multiDexEnabled true

ndk { abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" }
Expand Down Expand Up @@ -171,7 +171,9 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = '17' }
kotlinOptions { jvmTarget = '17'
freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode']
}

buildFeatures { viewBinding = true }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package org.horizontal.tella.mobile.certificate

import android.util.Base64
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.ExtendedKeyUsage
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
import org.bouncycastle.asn1.x509.KeyPurposeId
import org.bouncycastle.asn1.x509.KeyUsage
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
Expand All @@ -31,6 +33,31 @@ object CertificateUtils {
}


/** Session client certificate for the sender role (no IP SAN required). */
fun generateSelfSignedClientCertificate(keyPair: KeyPair): X509Certificate {
val now = Date()
val until = Date(now.time + 365L * 24 * 60 * 60 * 1000)
val serial = BigInteger(128, SecureRandom())
val dn = X500Name("CN=Tella P2P Client, O=Tella, C=US")
val certBuilder = JcaX509v3CertificateBuilder(
dn, serial, now, until, dn, keyPair.public
)
certBuilder.addExtension(
Extension.basicConstraints, true, org.bouncycastle.asn1.x509.BasicConstraints(false)
)
certBuilder.addExtension(
Extension.keyUsage, true, KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment)
)
// Some TLS stacks refuse client certs that carry KeyUsage but no clientAuth EKU.
certBuilder.addExtension(
Extension.extendedKeyUsage, false, ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)
)
val signer = JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.private)
val certHolder = certBuilder.build(signer)
return JcaX509CertificateConverter().setProvider(org.bouncycastle.jce.provider.BouncyCastleProvider())
.getCertificate(certHolder)
}

fun generateSelfSignedCertificate(keyPair: KeyPair, ipAddresses: List<String>): X509Certificate {
val uniqueIps = ipAddresses.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
require(uniqueIps.isNotEmpty()) { "At least one IP is required for the P2P certificate SAN." }
Expand Down Expand Up @@ -106,6 +133,41 @@ object CertificateUtils {
}
}

/**
* Server-side trust manager that accepts only TLS client certificates whose leaf DER hash
* matches [expectedLeafCertSha256Hex]. Used after the receiver pins the sender hash.
*/
fun getLeafCertPinnedClientTrustManager(expectedLeafCertSha256Hex: String): X509TrustManager {
val expected = normalizeHex(expectedLeafCertSha256Hex)
return object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
val leaf = chain?.firstOrNull()
?: throw CertificateException("Empty client certificate chain")
leaf.checkValidity()
val actual = normalizeHex(getLeafCertificateDerSha256Hex(leaf))
if (actual != expected) {
throw CertificateException("Client certificate hash mismatch")
}
}
}
}

/**
* Accepts any valid client certificate (for extracting sender hash before pinning).
*/
fun getClientFingerprintCollectionTrustManager(): X509TrustManager =
object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
val leaf = chain?.firstOrNull()
?: throw CertificateException("Empty client certificate chain")
leaf.checkValidity()
}
}

/**
* Bootstrap trust manager for first-contact fingerprint collection:
* it requires a present, currently-valid X.509 leaf cert, but does not require CA-chain trust.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.security.KeyPair
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.X509Certificate
Expand All @@ -28,6 +29,7 @@ import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
import org.horizontal.tella.mobile.data.peertopeer.PeerMtlsSsl
import kotlin.coroutines.resume

/**
Expand All @@ -48,15 +50,34 @@ object FingerprintFetcher {
// Public: PRE-PIN handshake to read cert (no HTTP), then compute hashes
// ---------------------------------------------------------------------
suspend fun fetch(context: Context, ip: String, port: Int): Result<FingerprintResult> =
withContext(Dispatchers.IO) {
fetchWithClientIdentity(context, ip, port, clientKeyPair = null, clientCertificate = null)

/**
* TLS handshake that optionally presents a sender client certificate (mTLS).
*/
suspend fun fetchWithClientIdentity(
context: Context,
ip: String,
port: Int,
clientKeyPair: KeyPair?,
clientCertificate: X509Certificate?,
pinnedReceiverHash: String? = null,
): Result<FingerprintResult> = withContext(Dispatchers.IO) {
try {
val wifi = getWifiNetworkPreferringValidated(context)

// 1) Quick TCP probe (fail fast if nothing listening)
probeTcp(ip, port, wifi)

// 2) TLS handshake (system CA validation) to read leaf cert
createBoundTlsSocket(ip, port, wifi).use { tls ->
// 2) TLS handshake to read peer leaf cert
createBoundTlsSocket(
ip = ip,
port = port,
wifi = wifi,
clientKeyPair = clientKeyPair,
clientCertificate = clientCertificate,
pinnedReceiverHash = pinnedReceiverHash,
).use { tls ->
val cert = tls.session.peerCertificates.first() as X509Certificate
return@withContext Result.success(fingerprintFromCert(cert))
}
Expand Down Expand Up @@ -92,11 +113,21 @@ object FingerprintFetcher {
fun buildClientPinnedByCertHash(
expectedCertSha256Hex: String,
hostForRequests: String,
network: Network? = null
network: Network? = null,
clientKeyPair: KeyPair? = null,
clientCertificate: X509Certificate? = null,
): OkHttpClient {
val trustManager = CertificateUtils.getLeafCertPinnedTrustManager(expectedCertSha256Hex)
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), SecureRandom())
val trustManager: X509TrustManager = if (expectedCertSha256Hex.isBlank()) {
CertificateUtils.getFingerprintCollectionTrustManager()
} else {
CertificateUtils.getLeafCertPinnedTrustManager(expectedCertSha256Hex)
}
val sslContext = if (clientKeyPair != null && clientCertificate != null) {
PeerMtlsSsl.createSenderSslContext(clientKeyPair, clientCertificate, expectedCertSha256Hex.takeIf { it.isNotBlank() })
} else {
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), SecureRandom())
}
}

val builder = OkHttpClient.Builder()
Expand Down Expand Up @@ -180,8 +211,19 @@ object FingerprintFetcher {
// Internals used by fetch()
// ---------------------------------------------------------------------

private fun createBoundTlsSocket(ip: String, port: Int, wifi: Network?): SSLSocket {
val sslContext = CertificateUtils.getFingerprintCollectionSSLContext()
private fun createBoundTlsSocket(
ip: String,
port: Int,
wifi: Network?,
clientKeyPair: KeyPair? = null,
clientCertificate: X509Certificate? = null,
pinnedReceiverHash: String? = null,
): SSLSocket {
val sslContext = if (clientKeyPair != null && clientCertificate != null) {
PeerMtlsSsl.createSenderSslContext(clientKeyPair, clientCertificate, pinnedReceiverHash)
} else {
CertificateUtils.getFingerprintCollectionSSLContext()
}
val factory = sslContext.socketFactory as SSLSocketFactory

val s = factory.createSocket() as SSLSocket
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.horizontal.tella.mobile.data.peertopeer

import timber.log.Timber
import java.net.Socket
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedTrustManager

/**
* Trust manager used only to let self-signed peer certificates complete the TLS handshake
* so we can capture the client certificate.
*
* This does NOT authorize the peer.
* Authorization happens after the handshake by comparing the captured client certificate
* hash against the QR-scanned pinned sender hash.
*
* Any request whose certificate is missing or does not match the pinned hash must be rejected
* by the route handler.
*/
internal class PeerClientCertCapturingTrustManager : X509ExtendedTrustManager() {

private val clientLeafByEngine = ConcurrentHashMap<SSLEngine, X509Certificate>()
@Volatile
private var pendingClientLeaf: X509Certificate? = null

fun peekClientLeafForEngine(engine: SSLEngine): X509Certificate? =
clientLeafByEngine[engine] ?: pendingClientLeaf

fun peekPendingClientLeaf(): X509Certificate? = pendingClientLeaf

fun consumePendingClientLeaf(): X509Certificate? =
pendingClientLeaf.also { pendingClientLeaf = null }

private fun rememberClient(engine: SSLEngine?, chain: Array<out X509Certificate>) {
val leaf = chain.firstOrNull() ?: return
pendingClientLeaf = leaf
if (engine != null) {
clientLeafByEngine[engine] = leaf
Timber.d(
"P2P mTLS trust manager captured client leaf (engine=%d)",
System.identityHashCode(engine),
)
} else {
Timber.d("P2P mTLS trust manager captured client leaf (no engine)")
}
}

override fun checkClientTrusted(
chain: Array<out X509Certificate>,
authType: String,
engine: SSLEngine,
) {
Timber.d(
"P2P mTLS checkClientTrusted(engine) authType=%s chain=%d subject=%s",
authType, chain.size, chain.firstOrNull()?.subjectX500Principal,
)
rememberClient(engine, chain)
}

override fun checkClientTrusted(
chain: Array<out X509Certificate>,
authType: String,
socket: Socket,
) {
Timber.d(
"P2P mTLS checkClientTrusted(socket) authType=%s chain=%d subject=%s",
authType, chain.size, chain.firstOrNull()?.subjectX500Principal,
)
rememberClient(null, chain)
}

override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
Timber.d(
"P2P mTLS checkClientTrusted(plain) authType=%s chain=%d subject=%s",
authType, chain.size, chain.firstOrNull()?.subjectX500Principal,
)
rememberClient(null, chain)
}

override fun checkServerTrusted(
chain: Array<out X509Certificate>,
authType: String,
engine: SSLEngine,
) = Unit

override fun checkServerTrusted(
chain: Array<out X509Certificate>,
authType: String,
socket: Socket,
) = Unit

override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) = Unit

override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.horizontal.tella.mobile.data.peertopeer

import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.handler.ssl.SslHandler
import io.netty.handler.ssl.SslHandshakeCompletionEvent
import io.netty.util.AttributeKey
import io.netty.util.concurrent.GenericFutureListener
import timber.log.Timber
import java.security.cert.X509Certificate

internal val PEER_CLIENT_LEAF_CERT_KEY: AttributeKey<X509Certificate> =
AttributeKey.valueOf("peerClientLeafCert")

/** Stores the sender leaf certificate on the channel after mTLS handshake completes. */
internal class PeerClientCertCaptureHandler : ChannelInboundHandlerAdapter() {

override fun handlerAdded(ctx: ChannelHandlerContext) {
val sslHandler = ctx.pipeline().get(SslHandler::class.java) ?: return
sslHandler.handshakeFuture().addListener(handshakeListener(ctx))
}

override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) {
if (evt is SslHandshakeCompletionEvent && evt.isSuccess) {
storePeerLeaf(ctx, "handshake-event")
}
super.userEventTriggered(ctx, evt)
}

private fun handshakeListener(ctx: ChannelHandlerContext): GenericFutureListener<io.netty.util.concurrent.Future<in io.netty.channel.Channel>> =
GenericFutureListener { future ->
if (future.isSuccess) {
storePeerLeaf(ctx, "handshake-future")
} else {
Timber.w(future.cause(), "P2P server mTLS handshake failed")
}
}

private fun storePeerLeaf(ctx: ChannelHandlerContext, source: String) {
if (ctx.channel().attr(PEER_CLIENT_LEAF_CERT_KEY).get() != null) return
capturePeerLeaf(ctx)?.let { cert ->
ctx.channel().attr(PEER_CLIENT_LEAF_CERT_KEY).set(cert)
Timber.d("P2P server captured client leaf cert via %s", source)
} ?: Timber.w("P2P server: mTLS handshake ok but no client leaf cert captured")
}

private fun capturePeerLeaf(ctx: ChannelHandlerContext): X509Certificate? {
val sslHandler = ctx.pipeline().get(SslHandler::class.java) ?: return null
val engine = sslHandler.engine()
PeerMtlsSsl.peekCapturedServerClientCert(engine)?.let { return it }
PeerMtlsSsl.peekPendingServerClientCert()?.let { return it }
return runCatching {
sslHandler.engine().session.peerCertificates?.firstOrNull() as? X509Certificate
}.getOrElse { error ->
Timber.w(error, "Failed to read peer client certificate from SSLSession")
null
}
}
}
Loading