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