Add secure key storage

File uploads
This commit is contained in:
Kieran 2023-08-29 14:37:14 +01:00
parent c4ec164047
commit e0884c17f1
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
11 changed files with 308 additions and 28 deletions

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_34_extension_level_7_arm64-v8a.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2023-07-17T10:10:40.050367Z" />
</component>
</project>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" /> <option name="version" value="1.9.10" />
</component> </component>
</project> </project>

View File

@ -1,6 +1,7 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
} }
android { android {
@ -9,10 +10,10 @@ android {
defaultConfig { defaultConfig {
applicationId "social.snort.app" applicationId "social.snort.app"
minSdk 24 minSdk 26
targetSdk 33 targetSdk 33
versionCode 1 versionCode 3
versionName "1.0.11" versionName "1.0.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -50,6 +51,12 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.5.1' implementation 'androidx.activity:activity-compose:1.5.1'
implementation 'androidx.webkit:webkit:1.4.0' 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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View File

@ -3,7 +3,8 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<application <application
android:allowBackup="true" android:allowBackup="false"
android:name=".Snort"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -11,6 +12,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -24,4 +27,7 @@
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CLIPBOARD" />
<uses-permission android:name="android.permission.WRITE_CLIPBOARD" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest> </manifest>

View File

@ -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
}
}

View File

@ -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<List<String>>,
val content: String,
val sig: HexKey? = null
)

View File

@ -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)
}
}

View File

@ -3,16 +3,22 @@ package social.snort.app
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewClientCompat
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var getContentCallback: ValueCallback<Array<Uri>>? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -22,11 +28,35 @@ class MainActivity : ComponentActivity() {
webView.settings.domStorageEnabled = true webView.settings.domStorageEnabled = true
webView.settings.javaScriptEnabled = true webView.settings.javaScriptEnabled = true
webView.settings.databaseEnabled = 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() val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this)) .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this)).build()
.build()
webView.webViewClient = LocalContentWebViewClient(assetLoader) 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<Array<Uri>>,
fileChooserParams: FileChooserParams
): Boolean {
getContentCallback = filePathCallback
getContent.launch("*/*")
return true
}
}
webView.loadUrl("https://appassets.androidplatform.net/") webView.loadUrl("https://appassets.androidplatform.net/")
} }
} }
@ -35,8 +65,7 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa
WebViewClientCompat() { WebViewClientCompat() {
@RequiresApi(21) @RequiresApi(21)
override fun shouldInterceptRequest( override fun shouldInterceptRequest(
view: WebView, view: WebView, request: WebResourceRequest
request: WebResourceRequest
): WebResourceResponse? { ): WebResourceResponse? {
// rewrite root url to index.html // rewrite root url to index.html
if (request.url.path.equals("/") && request.url.host.equals("appassets.androidplatform.net")) { 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")) { if (request.url.host.equals("appassets.androidplatform.net")) {
return false return false
} }
val intent = Intent(Intent.ACTION_VIEW, request.url) try {
view.context.startActivity(intent) val intent = Intent(Intent.ACTION_VIEW, request.url)
view.context.startActivity(intent)
} catch (ex: Exception) {
// ignored
}
return true return true
} }
} }

View File

@ -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<Event>(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)
}
}

View File

@ -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
}
}

View File

@ -3,4 +3,5 @@ plugins {
id 'com.android.application' version '8.0.2' apply false id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' 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.android' version '1.7.20' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10'
} }