Add secure key storage
File uploads
This commit is contained in:
parent
c4ec164047
commit
e0884c17f1
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@ -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>
|
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@ -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>
|
@ -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'
|
||||
|
@ -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>
|
29
app/src/main/java/social/snort/app/EncryptedStorage.kt
Normal file
29
app/src/main/java/social/snort/app/EncryptedStorage.kt
Normal 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
|
||||
}
|
||||
}
|
19
app/src/main/java/social/snort/app/Event.kt
Normal file
19
app/src/main/java/social/snort/app/Event.kt
Normal 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
|
||||
)
|
70
app/src/main/java/social/snort/app/HexUtils.kt
Normal file
70
app/src/main/java/social/snort/app/HexUtils.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
117
app/src/main/java/social/snort/app/Nip7Extension.kt
Normal file
117
app/src/main/java/social/snort/app/Nip7Extension.kt
Normal 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)
|
||||
}
|
||||
}
|
15
app/src/main/java/social/snort/app/Snort.kt
Normal file
15
app/src/main/java/social/snort/app/Snort.kt
Normal 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
|
||||
}
|
||||
}
|
@ -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'
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user