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"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" />
<option name="version" value="1.9.10" />
</component>
</project>

View File

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

View File

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

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.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<Array<Uri>>? = 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<Array<Uri>>,
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
}
}

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.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'
}