diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f33725a..7fe78ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "org.vextaproject.wallet" minSdk = 26 targetSdk = 36 - versionCode = 29 - versionName = "0.5.1" + versionCode = 30 + versionName = "0.6.0" } signingConfigs { @@ -55,6 +55,8 @@ android { } dependencies { + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.fragment:fragment-ktx:1.8.9") implementation("org.bitcoinj:bitcoinj-core:0.16.3") implementation("com.google.zxing:core:3.5.3") implementation( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bd5ba98..f056927 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + + + diff --git a/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt new file mode 100644 index 0000000..7a5818b --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt @@ -0,0 +1,594 @@ +package org.vextaproject.wallet + +import android.content.Context +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.File +import java.io.OutputStream +import java.math.BigInteger +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import kotlin.random.Random + +object HeaderSync { + + private val MAGIC = byteArrayOf(0x56, 0x54, 0x58, 0x31) + private const val PORT = 19333 + private const val PROTOCOL_VERSION = 70018 + + private const val GENESIS_HASH = + "0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1" + + private const val GENESIS_TIME = 1782770400L + private const val GENESIS_BITS = 0x1e0ffff0L + + private const val AVERAGING_WINDOW = 10 + private const val TARGET_SPACING = 600L + private const val TARGET_TIMESPAN = AVERAGING_WINDOW * TARGET_SPACING + private const val MIN_TIMESPAN = TARGET_TIMESPAN * 92 / 100 + private const val MAX_TIMESPAN = TARGET_TIMESPAN * 116 / 100 + + private val POW_LIMIT = + BigInteger.ONE.shiftLeft(256).subtract(BigInteger.ONE).shiftRight(20) + + data class VersionInfo( + val protocol: Int, + val height: Int, + val userAgent: String + ) + + data class ChainHeader( + val height: Int, + val raw: ByteArray?, + val hashWire: ByteArray, + val hashDisplay: String, + val time: Long, + val bits: Long + ) + + data class SyncResult( + val received: Int, + val cachedBefore: Int, + val chainHeight: Int, + val lastHash: String + ) + + fun loadCachedChain(context: Context): MutableList { + val chain = mutableListOf( + ChainHeader( + height = 0, + raw = null, + hashWire = hashHexToWireBytes(GENESIS_HASH), + hashDisplay = GENESIS_HASH, + time = GENESIS_TIME, + bits = GENESIS_BITS + ) + ) + + val file = File(context.filesDir, "vexta-headers.dat") + + if (!file.exists()) { + return chain + } + + val data = file.readBytes() + + if (data.size % 80 != 0) { + file.delete() + return chain + } + + try { + var offset = 0 + + while (offset < data.size) { + val raw = data.copyOfRange(offset, offset + 80) + appendVerifiedHeader(chain, raw) + offset += 80 + } + } catch (_: Exception) { + file.delete() + return mutableListOf(chain.first()) + } + + return chain + } + + fun saveChain(context: Context, chain: List) { + val file = File(context.filesDir, "vexta-headers.dat") + val temporary = File(context.filesDir, "vexta-headers.tmp") + + temporary.outputStream().use { output -> + chain.drop(1).forEach { header -> + output.write( + header.raw + ?: throw IllegalStateException("missing stored header") + ) + } + } + + if (file.exists()) { + file.delete() + } + + if (!temporary.renameTo(file)) { + throw IllegalStateException("unable to save header database") + } + } + + fun syncFromPeer( + host: String, + chain: MutableList + ): SyncResult { + Socket().use { socket -> + socket.soTimeout = 15000 + socket.connect(InetSocketAddress(host, PORT), 5000) + + val input = DataInputStream(socket.getInputStream()) + val output = socket.getOutputStream() + + sendMessage(output, "version", createVersionPayload(host)) + + var versionInfo: VersionInfo? = null + var gotVerack = false + var requested = false + val cachedBefore = chain.last().height + + repeat(200) { + val message = readMessage(input) + + when (message.first) { + "version" -> { + versionInfo = parseVersionPayload(message.second) + sendMessage(output, "verack", byteArrayOf()) + } + + "verack" -> gotVerack = true + + "ping" -> sendMessage(output, "pong", message.second) + + "headers" -> { + val received = parseHeaders(message.second, chain) + val remote = versionInfo + ?: throw IllegalStateException("missing node version") + + if (chain.last().height > remote.height) { + throw IllegalStateException( + "local verified chain exceeds peer height" + ) + } + + return SyncResult( + received = received, + cachedBefore = cachedBefore, + chainHeight = chain.last().height, + lastHash = chain.last().hashDisplay + ) + } + } + + if (versionInfo != null && gotVerack && !requested) { + sendMessage( + output, + "getheaders", + createGetHeadersPayload(chain.last().hashWire) + ) + requested = true + } + } + + throw IllegalStateException("headers response timeout") + } + } + + fun parseHeaders( + payload: ByteArray, + chain: MutableList + ): Int { + val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + val count = readCompactSize(buffer) + + if (count > 2000) { + throw IllegalStateException("too many headers: $count") + } + + repeat(count) { + if (buffer.remaining() < 81) { + throw IllegalStateException("truncated headers message") + } + + val raw = ByteArray(80) + buffer.get(raw) + + val transactionCount = readCompactSize(buffer) + + if (transactionCount != 0) { + throw IllegalStateException( + "headers message contains transactions" + ) + } + + appendVerifiedHeader(chain, raw) + } + + return count + } + + fun appendVerifiedHeader( + chain: MutableList, + raw: ByteArray + ) { + val previous = chain.last() + val previousHash = raw.copyOfRange(4, 36) + + if (!previousHash.contentEquals(previous.hashWire)) { + throw IllegalStateException( + "header linkage failed at height ${previous.height + 1}" + ) + } + + val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN) + val time = buffer.getInt(68).toLong() and 0xffffffffL + val bits = buffer.getInt(72).toLong() and 0xffffffffL + + val target = compactToTarget(bits) + + if (target <= BigInteger.ZERO || target > POW_LIMIT) { + throw IllegalStateException( + "invalid target at height ${previous.height + 1}" + ) + } + + val hashWire = doubleSha256(raw) + val hashNumber = BigInteger(1, hashWire.reversedArray()) + + if (hashNumber > target) { + throw IllegalStateException( + "proof of work failed at height ${previous.height + 1}" + ) + } + + val expectedBits = expectedBits(chain) + + if (bits != expectedBits) { + throw IllegalStateException( + "difficulty mismatch at height ${previous.height + 1}: " + + "expected ${expectedBits.toString(16)}, " + + "received ${bits.toString(16)}" + ) + } + + chain.add( + ChainHeader( + height = previous.height + 1, + raw = raw, + hashWire = hashWire, + hashDisplay = hashWire.reversedArray().toHex(), + time = time, + bits = bits + ) + ) + } + + fun expectedBits(chain: List): Long { + val last = chain.last() + + if (last.height < AVERAGING_WINDOW) { + return targetToCompact(POW_LIMIT) + } + + val first = chain[last.height - AVERAGING_WINDOW] + + var actualTimespan = + medianTimePast(chain, last.height) - + medianTimePast(chain, first.height) + + actualTimespan = + TARGET_TIMESPAN + + (actualTimespan - TARGET_TIMESPAN) / 4 + + actualTimespan = actualTimespan.coerceIn( + MIN_TIMESPAN, + MAX_TIMESPAN + ) + + var newTarget = + compactToTarget(last.bits) + .multiply(BigInteger.valueOf(actualTimespan)) + .divide(BigInteger.valueOf(TARGET_TIMESPAN)) + + if (newTarget > POW_LIMIT) { + newTarget = POW_LIMIT + } + + return targetToCompact(newTarget) + } + + fun medianTimePast( + chain: List, + height: Int + ): Long { + val start = maxOf(0, height - 10) + val times = (start..height) + .map { chain[it].time } + .sorted() + + return times[times.size / 2] + } + + fun compactToTarget(compact: Long): BigInteger { + val size = ((compact ushr 24) and 0xff).toInt() + var word = compact and 0x007fffffL + + var value = BigInteger.valueOf(word) + + value = if (size <= 3) { + value.shiftRight(8 * (3 - size)) + } else { + value.shiftLeft(8 * (size - 3)) + } + + return if ((compact and 0x00800000L) != 0L) { + value.negate() + } else { + value + } + } + + fun targetToCompact(targetValue: BigInteger): Long { + if (targetValue == BigInteger.ZERO) { + return 0 + } + + val negative = targetValue.signum() < 0 + val target = targetValue.abs() + + var size = (target.bitLength() + 7) / 8 + + var compact = if (size <= 3) { + target.toLong() shl (8 * (3 - size)) + } else { + target.shiftRight(8 * (size - 3)).toLong() + } + + if ((compact and 0x00800000L) != 0L) { + compact = compact ushr 8 + size++ + } + + compact = compact and 0x007fffffL + compact = compact or (size.toLong() shl 24) + + if (negative && (compact and 0x007fffffL) != 0L) { + compact = compact or 0x00800000L + } + + return compact + } + + fun createGetHeadersPayload(locatorHash: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + + writeInt32LE(out, PROTOCOL_VERSION) + writeCompactSize(out, 1) + out.write(locatorHash) + out.write(ByteArray(32)) + + return out.toByteArray() + } + + fun readMessage(input: DataInputStream): Pair { + val header = ByteArray(24) + input.readFully(header) + + if (!header.copyOfRange(0, 4).contentEquals(MAGIC)) { + throw IllegalStateException("invalid Vexta network magic") + } + + val command = header.copyOfRange(4, 16) + .takeWhile { it.toInt() != 0 } + .toByteArray() + .toString(Charsets.US_ASCII) + + val payloadLength = ByteBuffer.wrap(header, 16, 4) + .order(ByteOrder.LITTLE_ENDIAN) + .int + + if (payloadLength < 0 || payloadLength > 4_000_000) { + throw IllegalStateException("invalid payload length") + } + + val expectedChecksum = header.copyOfRange(20, 24) + val payload = ByteArray(payloadLength) + input.readFully(payload) + + val actualChecksum = doubleSha256(payload).copyOfRange(0, 4) + + if (!expectedChecksum.contentEquals(actualChecksum)) { + throw IllegalStateException("invalid message checksum") + } + + return command to payload + } + + fun createVersionPayload(peerHost: String): ByteArray { + val out = ByteArrayOutputStream() + + writeInt32LE(out, PROTOCOL_VERSION) + writeInt64LE(out, 0) + writeInt64LE(out, System.currentTimeMillis() / 1000) + + writeNetworkAddress(out, peerHost, PORT) + writeNetworkAddress(out, "0.0.0.0", 0) + + writeInt64LE(out, Random.nextLong()) + writeVarString(out, "/VextaAndroidWallet:0.1.0/") + writeInt32LE(out, 0) + out.write(0) + + return out.toByteArray() + } + + fun parseVersionPayload(payload: ByteArray): VersionInfo { + val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + + if (payload.size < 80) { + throw IllegalStateException("short version payload") + } + + val version = buffer.int + buffer.long + buffer.long + buffer.position(buffer.position() + 26) + buffer.position(buffer.position() + 26) + buffer.long + + val userAgent = readVarString(buffer) + val height = if (buffer.remaining() >= 4) buffer.int else 0 + + return VersionInfo(version, height, userAgent) + } + + fun sendMessage( + output: OutputStream, + command: String, + payload: ByteArray + ) { + val message = ByteArrayOutputStream() + + message.write(MAGIC) + + val commandBytes = ByteArray(12) + command.toByteArray(Charsets.US_ASCII).copyInto( + destination = commandBytes, + endIndex = minOf(command.length, 12) + ) + + message.write(commandBytes) + writeInt32LE(message, payload.size) + message.write(doubleSha256(payload).copyOfRange(0, 4)) + message.write(payload) + + output.write(message.toByteArray()) + output.flush() + } + + fun writeNetworkAddress( + out: ByteArrayOutputStream, + host: String, + port: Int + ) { + writeInt64LE(out, 0) + + val address = InetAddress.getByName(host).address + val ipv6 = ByteArray(16) + + if (address.size == 4) { + ipv6[10] = 0xff.toByte() + ipv6[11] = 0xff.toByte() + address.copyInto(ipv6, 12) + } else { + address.copyInto(ipv6) + } + + out.write(ipv6) + out.write((port shr 8) and 0xff) + out.write(port and 0xff) + } + + fun writeVarString(out: ByteArrayOutputStream, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeCompactSize(out, bytes.size) + out.write(bytes) + } + + fun readVarString(buffer: ByteBuffer): String { + val length = readCompactSize(buffer) + + if (length > buffer.remaining()) { + throw IllegalStateException("invalid string length") + } + + val bytes = ByteArray(length) + buffer.get(bytes) + + return bytes.toString(Charsets.UTF_8) + } + + fun writeCompactSize(out: ByteArrayOutputStream, value: Int) { + when { + value < 253 -> out.write(value) + + value <= 0xffff -> { + out.write(253) + out.write(value and 0xff) + out.write((value shr 8) and 0xff) + } + + else -> { + out.write(254) + writeInt32LE(out, value) + } + } + } + + fun readCompactSize(buffer: ByteBuffer): Int { + if (!buffer.hasRemaining()) { + throw IllegalStateException("missing compact size") + } + + return when (val first = buffer.get().toInt() and 0xff) { + 253 -> buffer.short.toInt() and 0xffff + 254 -> buffer.int + + 255 -> { + val value = buffer.long + + if (value < 0 || value > Int.MAX_VALUE) { + throw IllegalStateException("compact size too large") + } + + value.toInt() + } + + else -> first + } + } + + fun hashHexToWireBytes(hash: String): ByteArray = + hash.removePrefix("0x") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .reversedArray() + + fun ByteArray.toHex(): String = + joinToString("") { "%02x".format(it.toInt() and 0xff) } + + fun writeInt32LE(out: ByteArrayOutputStream, value: Int) { + out.write( + ByteBuffer.allocate(4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(value) + .array() + ) + } + + fun writeInt64LE(out: ByteArrayOutputStream, value: Long) { + out.write( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(value) + .array() + ) + } + + fun doubleSha256(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(digest.digest(data)) + } +} diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index ab86743..9358eca 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -1,6 +1,9 @@ package org.vextaproject.wallet -import android.app.Activity +import androidx.fragment.app.FragmentActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import android.app.PendingIntent import android.app.NotificationManager import android.app.NotificationChannel @@ -66,7 +69,7 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -class WalletActivity : Activity() { +class WalletActivity : FragmentActivity() { companion object { private const val PREFS = "vexta_wallet" @@ -124,11 +127,14 @@ class WalletActivity : Activity() { if ( !isFinishing && !isDestroyed && - hasWindowFocus() && walletExists() && - walletBalanceView != null + !blockchainScanRunning ) { - startHeaderSyncAndScan() + if (hasWindowFocus()) { + startHeaderSyncAndScan() + } else { + startBackgroundHeaderSyncAndScan() + } } refreshHandler.postDelayed(this, 60_000L) @@ -238,6 +244,15 @@ class WalletActivity : Activity() { createPaymentNotificationChannel() requestNotificationPermission() + val monitoringIntent = + Intent(this, WalletMonitoringService::class.java) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(monitoringIntent) + } else { + startService(monitoringIntent) + } + val filter = IntentFilter( DownloadManager.ACTION_DOWNLOAD_COMPLETE ) @@ -254,7 +269,12 @@ class WalletActivity : Activity() { } if (walletExists()) { - showMainWallet() + authenticateWallet( + title = "Unlock Vexta Wallet", + subtitle = "Use biometrics or your device screen lock" + ) { + showMainWallet() + } } else { showWelcome() } @@ -680,6 +700,39 @@ class WalletActivity : Activity() { } } + private fun startBackgroundHeaderSyncAndScan() { + if (blockchainScanRunning) { + return + } + + blockchainScanRunning = true + + Thread { + try { + val chain = HeaderSync.loadCachedChain(this) + + for (peer in listOf("87.106.99.23", "74.208.53.160")) { + try { + val result = HeaderSync.syncFromPeer(peer, chain) + + if (result.received > 0) { + HeaderSync.saveChain(this, chain) + } + } catch (_: Exception) { + } + } + + runOnUiThread { + runBlockchainScan() + } + } catch (_: Exception) { + runOnUiThread { + blockchainScanRunning = false + } + } + }.start() + } + private fun startHeaderSyncAndScan() { if (blockchainScanRunning) { return @@ -1878,8 +1931,12 @@ class WalletActivity : Activity() { return@primaryButton } - try { - val words = loadMnemonic() + authenticateWallet( + title = "Authorize payment", + subtitle = "Authenticate before signing this VTX transaction" + ) { + try { + val words = loadMnemonic() .trim() .split(Regex("\\s+")) @@ -2093,6 +2150,7 @@ class WalletActivity : Activity() { ), Toast.LENGTH_LONG ).show() + } } }) } @@ -2226,6 +2284,93 @@ class WalletActivity : Activity() { } } + private fun authenticateWallet( + title: String, + subtitle: String, + onSuccess: () -> Unit + ) { + val authenticators = + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + + val biometricManager = BiometricManager.from(this) + + if ( + biometricManager.canAuthenticate(authenticators) != + BiometricManager.BIOMETRIC_SUCCESS + ) { + AlertDialog.Builder(this) + .setTitle("Device security required") + .setMessage( + "Vexta Wallet requires biometrics or a device " + + "PIN, pattern, or password. Configure a screen " + + "lock in Android security settings and try again." + ) + .setNegativeButton("Cancel", null) + .setPositiveButton("Security settings") { _, _ -> + startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS) + ) + } + .show() + return + } + + val executor = ContextCompat.getMainExecutor(this) + + val prompt = BiometricPrompt( + this, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + onSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + + Toast.makeText( + this@WalletActivity, + "Authentication not recognized", + Toast.LENGTH_SHORT + ).show() + } + + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError( + errorCode, + errString + ) + + if ( + errorCode != BiometricPrompt.ERROR_USER_CANCELED && + errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON + ) { + Toast.makeText( + this@WalletActivity, + errString, + Toast.LENGTH_LONG + ).show() + } + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(authenticators) + .build() + + prompt.authenticate(promptInfo) + } + private fun confirmShowSeed() { AlertDialog.Builder(this) .setTitle("Security warning") @@ -2234,9 +2379,14 @@ class WalletActivity : Activity() { "Make sure nobody can see your screen." ) .setNegativeButton("Cancel", null) - .setPositiveButton("Show") { _, _ -> - val words = loadMnemonic().split(" ") - showSeedBackup(words) + .setPositiveButton("Authenticate") { _, _ -> + authenticateWallet( + title = "Show recovery phrase", + subtitle = "Authenticate to reveal your recovery phrase" + ) { + val words = loadMnemonic().split(" ") + showSeedBackup(words) + } } .show() } diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt new file mode 100644 index 0000000..4bf70e4 --- /dev/null +++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt @@ -0,0 +1,63 @@ +package org.vextaproject.wallet + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder + +class WalletMonitoringService : Service() { + + companion object { + private const val CHANNEL_ID = "vexta_wallet_monitoring" + private const val NOTIFICATION_ID = 7001 + } + + override fun onCreate() { + super.onCreate() + + val manager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Wallet monitoring", + NotificationManager.IMPORTANCE_LOW + ) + ) + + val intent = Intent(this, WalletActivity::class.java) + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentTitle("Vexta Wallet") + .setContentText("Monitoring active") + .setContentIntent(pendingIntent) + .setOngoing(true) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + + startForeground(NOTIFICATION_ID, notification) + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int + ): Int { + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null +}