diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index d23add3..0000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e1eea1d..f8467b4 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ff2d709..ec7600b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' } android { @@ -9,10 +10,10 @@ android { defaultConfig { applicationId "social.snort.app" - minSdk 24 + minSdk 26 targetSdk 33 - versionCode 1 - versionName "1.0.11" + versionCode 3 + versionName "1.0.12" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -50,6 +51,12 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.activity:activity-compose:1.5.1' implementation 'androidx.webkit:webkit:1.4.0' + implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1' + + // Bitcoin secp256k1 bindings to Android + api 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f046976..f753bd4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + + \ No newline at end of file diff --git a/app/src/main/java/social/snort/app/EncryptedStorage.kt b/app/src/main/java/social/snort/app/EncryptedStorage.kt new file mode 100644 index 0000000..08ae4e6 --- /dev/null +++ b/app/src/main/java/social/snort/app/EncryptedStorage.kt @@ -0,0 +1,29 @@ +package social.snort.app + +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +object EncryptedStorage { + private const val PREFERENCES_NAME = "secret_keeper" + + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } + + fun preferences(npub: String? = null): EncryptedSharedPreferences { + val context = Snort.instance + val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val preferencesName = prefsFileName(npub) + + return EncryptedSharedPreferences.create( + context, + preferencesName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) as EncryptedSharedPreferences + } +} diff --git a/app/src/main/java/social/snort/app/Event.kt b/app/src/main/java/social/snort/app/Event.kt new file mode 100644 index 0000000..a7e7f89 --- /dev/null +++ b/app/src/main/java/social/snort/app/Event.kt @@ -0,0 +1,19 @@ +package social.snort.app + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +data class Event( + val id: HexKey, + @SerialName("pubkey") + val pubKey: HexKey, + @SerialName("created_at") + val createdAt: Long, + val kind: Int, + val tags: List>, + val content: String, + + val sig: HexKey? = null +) \ No newline at end of file diff --git a/app/src/main/java/social/snort/app/HexUtils.kt b/app/src/main/java/social/snort/app/HexUtils.kt new file mode 100644 index 0000000..d50e4b4 --- /dev/null +++ b/app/src/main/java/social/snort/app/HexUtils.kt @@ -0,0 +1,70 @@ +package social.snort.app + +/** Makes the distinction between String and Hex **/ +typealias HexKey = String + +fun ByteArray.toHexKey(): HexKey { + return Hex.encode(this) +} + +fun HexKey.hexToByteArray(): ByteArray { + return Hex.decode(this) +} + +object HexValidator { + private fun isHex2(c: Char): Boolean { + return when (c) { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true + else -> false + } + } + + fun isHex(hex: String?): Boolean { + if (hex == null) return false + var isHex = true + for (c in hex.toCharArray()) { + if (!isHex2(c)) { + isHex = false + break + } + } + return isHex + } +} + +object Hex { + private val hexCode = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + + // Faster if no calculations are needed. + private fun hexToBin(ch: Char): Int = when (ch) { + in '0'..'9' -> ch - '0' + in 'a'..'f' -> ch - 'a' + 10 + in 'A'..'F' -> ch - 'A' + 10 + else -> throw IllegalArgumentException("illegal hex character: $ch") + } + + @JvmStatic + fun decode(hex: String): ByteArray { + // faster version of hex decoder + require(hex.length % 2 == 0) + val outSize = hex.length / 2 + val out = ByteArray(outSize) + + for (i in 0 until outSize) { + out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte() + } + + return out + } + + @JvmStatic + fun encode(input: ByteArray): String { + val len = input.size + val out = CharArray(len * 2) + for (i in 0 until len) { + out[i*2] = hexCode[(input[i].toInt() shr 4) and 0xF] + out[i*2+1] = hexCode[input[i].toInt() and 0xF] + } + return String(out) + } +} \ No newline at end of file diff --git a/app/src/main/java/social/snort/app/MainActivity.kt b/app/src/main/java/social/snort/app/MainActivity.kt index d673db6..d03a80e 100644 --- a/app/src/main/java/social/snort/app/MainActivity.kt +++ b/app/src/main/java/social/snort/app/MainActivity.kt @@ -3,16 +3,22 @@ package social.snort.app import android.content.Intent import android.net.Uri import android.os.Bundle +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat class MainActivity : ComponentActivity() { + private var getContentCallback: ValueCallback>? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -22,11 +28,35 @@ class MainActivity : ComponentActivity() { webView.settings.domStorageEnabled = true webView.settings.javaScriptEnabled = true webView.settings.databaseEnabled = true; + val getContent = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) { + getContentCallback!!.onReceiveValue(arrayOf(uri)) + } else { + getContentCallback!!.onReceiveValue(emptyArray()) + } + getContentCallback = null + } val assetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this)) - .build() + .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this)).build() webView.webViewClient = LocalContentWebViewClient(assetLoader) + webView.addJavascriptInterface(Nip7Extension(), "nostr_os"); + webView.webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + request.grant(request.resources) + } + + override fun onShowFileChooser( + vw: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + getContentCallback = filePathCallback + getContent.launch("*/*") + return true + } + } webView.loadUrl("https://appassets.androidplatform.net/") } } @@ -35,8 +65,7 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa WebViewClientCompat() { @RequiresApi(21) override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest + view: WebView, request: WebResourceRequest ): WebResourceResponse? { // rewrite root url to index.html if (request.url.path.equals("/") && request.url.host.equals("appassets.androidplatform.net")) { @@ -49,8 +78,12 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa if (request.url.host.equals("appassets.androidplatform.net")) { return false } - val intent = Intent(Intent.ACTION_VIEW, request.url) - view.context.startActivity(intent) + try { + val intent = Intent(Intent.ACTION_VIEW, request.url) + view.context.startActivity(intent) + } catch (ex: Exception) { + // ignored + } return true } } \ No newline at end of file diff --git a/app/src/main/java/social/snort/app/Nip7Extension.kt b/app/src/main/java/social/snort/app/Nip7Extension.kt new file mode 100644 index 0000000..c044c22 --- /dev/null +++ b/app/src/main/java/social/snort/app/Nip7Extension.kt @@ -0,0 +1,117 @@ +package social.snort.app + +import android.webkit.JavascriptInterface +import fr.acinq.secp256k1.Secp256k1 +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class Nip7Extension { + private val secp256k1 = Secp256k1.get() + private val h02 = ByteArray(1) { 0x02 } + private val random = SecureRandom() + + object PrefKeys { + const val PrivateKey = "private_key" + } + + @JavascriptInterface + fun getPublicKey(): String { + val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null) + if (key != null) { + return Hex.encode( + secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(Hex.decode(key))).copyOfRange(1, 33) + ) + } + throw error("Missing private key") + } + + @JavascriptInterface + fun signEvent(ev: String): String { + val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null) + ?: throw error("Missing private key") + + val json = Json { ignoreUnknownKeys = true } + println(ev) + val evParsed = json.decodeFromString(ev) + + val idArray = buildJsonArray { + add(0) + add(evParsed.pubKey) + add(evParsed.createdAt) + add(evParsed.kind) + add(buildJsonArray { + evParsed.tags.forEach { x -> add(buildJsonArray { x.forEach { y -> add(y) } }) } + }) + add(evParsed.content) + }.toString() + + println(idArray) + val evId = MessageDigest.getInstance("SHA-256").digest(idArray.encodeToByteArray()) + var sig = Hex.encode(secp256k1.signSchnorr(evId, Hex.decode(key), null)) + + return json.encodeToString( + Event( + Hex.encode(evId), + evParsed.pubKey, + evParsed.createdAt, + evParsed.kind, + evParsed.tags, + evParsed.content, + sig + ) + ) + } + + @JavascriptInterface + fun nip04_encrypt(msg: String, toKey: String): String { + val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null) + ?: throw error("Missing private key") + val sharedSecret = getSharedSecretNIP04(Hex.decode(key), Hex.decode(toKey)) + val iv = ByteArray(16) + random.nextBytes(iv) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + val ivBase64 = Base64.getEncoder().encodeToString(iv) + val encryptedMsg = cipher.doFinal(msg.toByteArray()) + val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg) + return "$encryptedMsgBase64?iv=$ivBase64" + } + + @JavascriptInterface + fun nip04_decrypt(msg: String, fromKey: String): String { + val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null) + ?: throw error("Missing private key") + val sharedSecret = getSharedSecretNIP04(Hex.decode(key), Hex.decode(fromKey)) + + val msgSplit = msg.split("?iv=") + val cipherText = Base64.getDecoder().decode(msgSplit[0]) + val iv = Base64.getDecoder().decode(msgSplit[1]) + + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv)) + return String(cipher.doFinal(cipherText)) + } + + @JavascriptInterface + fun saveKey(key: String) { + EncryptedStorage.preferences().edit().apply { + putString(PrefKeys.PrivateKey, key) + }.commit() + } + + /** + * @return 32B shared secret + */ + private fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray { + return secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey) + .copyOfRange(1, 33) + } +} \ No newline at end of file diff --git a/app/src/main/java/social/snort/app/Snort.kt b/app/src/main/java/social/snort/app/Snort.kt new file mode 100644 index 0000000..71e223d --- /dev/null +++ b/app/src/main/java/social/snort/app/Snort.kt @@ -0,0 +1,15 @@ +package social.snort.app + +import android.app.Application + +class Snort : Application() { + override fun onCreate() { + super.onCreate() + instance = this + } + + companion object { + lateinit var instance: Snort + private set + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4314313..ca37a0e 100644 --- a/build.gradle +++ b/build.gradle @@ -3,4 +3,5 @@ plugins { id 'com.android.application' version '8.0.2' apply false id 'com.android.library' version '8.0.2' apply false id 'org.jetbrains.kotlin.android' version '1.7.20' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' } \ No newline at end of file