mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-03 12:34:28 +00:00
NexoPass 1.3.0: Linux and Windows app, project split into shared/android/desktop
The PC app (Compose Multiplatform) uses the nexopass script's files, so the window and the terminal share accounts, site lists and the app password. Clipboard copies carry KDE's password hint. GitHub Actions builds the APK, .deb, .rpm, .msi and a portable Windows zip, and checks that passwords still match the script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b38efcaea0
commit
987cea431f
42 changed files with 1475 additions and 557 deletions
77
android/build.gradle.kts
Normal file
77
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nexoniarz.nexopass"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.nexoniarz.nexopass"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
val v = rootProject.extra["appVersion"] as String
|
||||
versionName = v
|
||||
// 1.3.0 -> 10300
|
||||
versionCode = v.split(".").map { it.toInt() }.let { it[0] * 10000 + it[1] * 100 + it[2] }
|
||||
}
|
||||
|
||||
sourceSets["main"].apply {
|
||||
java.srcDirs("src/main/java", "../shared/src")
|
||||
assets.srcDirs("../shared/resources")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// CI signs with the same key as local builds (from secrets), so the
|
||||
// app updates in place. Without it, the debug key is used.
|
||||
create("release") {
|
||||
val ks = System.getenv("NEXOPASS_KEYSTORE")
|
||||
if (ks != null) {
|
||||
storeFile = file(ks)
|
||||
storePassword = System.getenv("NEXOPASS_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("NEXOPASS_KEY_ALIAS")
|
||||
keyPassword = System.getenv("NEXOPASS_KEY_PASSWORD")
|
||||
} else {
|
||||
initWith(getByName("debug"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
packaging {
|
||||
// Bouncy Castle jars ship the same OSGi/versioned manifests.
|
||||
resources.excludes += setOf("META-INF/versions/9/OSGI-INF/MANIFEST.MF", "META-INF/DEPENDENCIES", "META-INF/LICENSE*", "META-INF/NOTICE*")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
// biometric pulls an old fragment that breaks file pickers (requestCode crash)
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.5")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
|
||||
implementation("org.bouncycastle:bcpg-jdk18on:1.79")
|
||||
}
|
||||
4
android/proguard-rules.pro
vendored
Normal file
4
android/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Argon2 from BouncyCastle is used directly, keep it intact.
|
||||
-keep class org.bouncycastle.crypto.generators.Argon2BytesGenerator { *; }
|
||||
-keep class org.bouncycastle.crypto.params.Argon2Parameters** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
27
android/src/main/AndroidManifest.xml
Normal file
27
android/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- No INTERNET permission: the app cannot send anything anywhere. -->
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="NexoPass"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NexoPass">
|
||||
<activity
|
||||
android:name=".android.MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.nexoniarz.nexopass.android
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PersistableBundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.nexoniarz.nexopass.app.AppState
|
||||
import com.nexoniarz.nexopass.app.KeyValue
|
||||
import com.nexoniarz.nexopass.app.LocalPlatform
|
||||
import com.nexoniarz.nexopass.app.Platform
|
||||
import com.nexoniarz.nexopass.app.Prefs
|
||||
import com.nexoniarz.nexopass.core.AesVaultCodec
|
||||
import com.nexoniarz.nexopass.core.NexoPass
|
||||
import com.nexoniarz.nexopass.ui.App
|
||||
import com.nexoniarz.nexopass.ui.NexoTheme
|
||||
import com.nexoniarz.nexopass.ui.ParticleBackground
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
// FragmentActivity because the system biometric prompt needs one.
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
// No screenshots, no preview in the recent apps screen. Debug builds allow
|
||||
// screenshots so the UI can be checked during development.
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
val platform = AndroidPlatform(this)
|
||||
val app = AppState(platform, NexoPass.loadWords(platform.resource("eff_large_wordlist.txt")))
|
||||
setContent {
|
||||
val dark = when (platform.prefs.theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
CompositionLocalProvider(LocalPlatform provides platform) {
|
||||
NexoTheme(dark) {
|
||||
ParticleBackground(dark = dark, animated = platform.prefs.particles) { App(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedPrefsKV(private val sp: SharedPreferences) : KeyValue {
|
||||
override fun getInt(key: String, def: Int) = sp.getInt(key, def)
|
||||
override fun putInt(key: String, value: Int) = sp.edit().putInt(key, value).apply()
|
||||
override fun getString(key: String, def: String) = sp.getString(key, def) ?: def
|
||||
override fun putString(key: String, value: String) = sp.edit().putString(key, value).apply()
|
||||
override fun getBool(key: String, def: Boolean) = sp.getBoolean(key, def)
|
||||
override fun putBool(key: String, value: Boolean) = sp.edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
class AndroidPlatform(private val activity: FragmentActivity) : Platform {
|
||||
override val prefs = Prefs(SharedPrefsKV(activity.getSharedPreferences("nexopass", Context.MODE_PRIVATE)))
|
||||
override val accountsDir = File(activity.filesDir, "accounts")
|
||||
override val codec = AesVaultCodec
|
||||
override val lock = AndroidLock(activity, activity.filesDir, prefs)
|
||||
override val migrated = migrate()
|
||||
override val aboutText = "Same passwords as nexopass on the PC. Nothing leaves this phone: the app has no internet permission."
|
||||
|
||||
/** 1.0/1.1 kept one vault in filesDir; it becomes the account "main". */
|
||||
private fun migrate(): Boolean {
|
||||
val old = File(activity.filesDir, "vault.bin")
|
||||
if (!old.exists() || File(accountsDir, "main").exists()) return false
|
||||
File(accountsDir, "main").mkdirs()
|
||||
old.renameTo(File(accountsDir, "main/vault.bin"))
|
||||
File(activity.filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak"))
|
||||
lock.wipe() // the old format held a single master
|
||||
prefs.currentAccount = "main"
|
||||
return true
|
||||
}
|
||||
|
||||
override fun resource(name: String) = activity.assets.open(name).use { it.readBytes() }
|
||||
|
||||
override val animationsEnabled =
|
||||
Settings.Global.getFloat(activity.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
|
||||
|
||||
/** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */
|
||||
override fun copySecret(text: String, seconds: Int) {
|
||||
val cm = activity.getSystemService(ClipboardManager::class.java)
|
||||
val clip = ClipData.newPlainText("NexoPass", text)
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean("android.content.extra.IS_SENSITIVE", true)
|
||||
}
|
||||
cm.setPrimaryClip(clip)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= 28) cm.clearPrimaryClip() else cm.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||
}, seconds * 1000L)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BackHandler(enabled: Boolean, onBack: () -> Unit) =
|
||||
androidx.activity.compose.BackHandler(enabled, onBack)
|
||||
|
||||
@Composable
|
||||
override fun AutoLock(app: AppState) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
var stoppedAt = 0L
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> stoppedAt = SystemClock.elapsedRealtime()
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val limit = app.prefs.autoLockMinutes * 60_000L
|
||||
// Never lock in the middle of the required app-unlock setup.
|
||||
if (stoppedAt > 0 && !app.needsAppSetup && SystemClock.elapsedRealtime() - stoppedAt >= limit) app.lock()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (String) -> Unit {
|
||||
val scope = rememberCoroutineScope()
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(content()) } }.isSuccess
|
||||
}
|
||||
onDone(ok)
|
||||
}
|
||||
}
|
||||
return { launcher.launch(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit {
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
onBytes(runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull())
|
||||
}
|
||||
return { launcher.launch(arrayOf("*/*")) }
|
||||
}
|
||||
}
|
||||
272
android/src/main/java/com/nexoniarz/nexopass/android/Security.kt
Normal file
272
android/src/main/java/com/nexoniarz/nexopass/android/Security.kt
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package com.nexoniarz.nexopass.android
|
||||
|
||||
import com.nexoniarz.nexopass.app.AppLock
|
||||
import com.nexoniarz.nexopass.app.Prefs
|
||||
import com.nexoniarz.nexopass.app.UnlockResult
|
||||
import com.nexoniarz.nexopass.app.Unlocked
|
||||
import com.nexoniarz.nexopass.core.NexoPass
|
||||
import com.nexoniarz.nexopass.ui.methodName
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.AEADBadTagException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** Every account's keys, encrypted under the app unlock. */
|
||||
object Keyring {
|
||||
private const val SEP = '\u001f'
|
||||
|
||||
fun encode(k: Map<String, Unlocked>) = k.entries.joinToString("\n") { (name, u) ->
|
||||
name + SEP + u.vaultKey.joinToString("") { "%02x".format(it) } + SEP + u.master.decodeToString()
|
||||
}.toByteArray()
|
||||
|
||||
fun decode(b: ByteArray): MutableMap<String, Unlocked> = b.decodeToString().lines()
|
||||
.mapNotNull { line ->
|
||||
val f = line.split(SEP, limit = 3)
|
||||
if (f.size != 3 || f[1].length != 64) null
|
||||
else f[0] to Unlocked(f[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray(), f[2].toByteArray())
|
||||
}.toMap(LinkedHashMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone app unlock (PIN, password or pattern, plus optional biometrics).
|
||||
*
|
||||
* quick.bin: salt, then the keyring encrypted twice. Inner layer: AES-GCM with
|
||||
* innerKey = Argon2id(secret, salt). Outer layer: AES-GCM with a key that lives
|
||||
* in the phone's secure hardware (Android Keystore) and never leaves it, so a
|
||||
* copied file is useless elsewhere. Wrong secrets are counted and wipe it.
|
||||
*
|
||||
* bio.bin: innerKey, encrypted with a Keystore key that only works right after
|
||||
* a fingerprint/face (or screen lock) check. New fingerprints invalidate it.
|
||||
*/
|
||||
class AndroidLock(private val activity: FragmentActivity, dir: File, private val prefs: Prefs) : AppLock {
|
||||
private val quickFile = File(dir, "quick.bin")
|
||||
private val bioFile = File(dir, "bio.bin")
|
||||
|
||||
override val isSet get() = quickFile.exists()
|
||||
override val method: String get() = if (isSet) prefs.quickMethod else "none"
|
||||
override val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off"
|
||||
override val methods = listOf("pin", "password", "pattern")
|
||||
|
||||
override fun problem(method: String, secret: String) = when (method) {
|
||||
"pin" -> if (secret.length < 4) "Use at least 4 digits (6 is better)." else null
|
||||
"pattern" -> if (secret.length < 4) "Connect at least 4 dots." else null
|
||||
else -> if (secret.length < 6) "Use at least 6 characters." else null
|
||||
}
|
||||
|
||||
/** New secret: new salt and key. Returns the inner key. Slow (Argon2). */
|
||||
override fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): ByteArray {
|
||||
disableBio() // it holds the old inner key
|
||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val key = innerKey(secret, salt)
|
||||
write(salt, key, keyring)
|
||||
prefs.quickMethod = method
|
||||
prefs.failedAttempts = 0
|
||||
return key
|
||||
}
|
||||
|
||||
/** Re-encrypts the keyring after accounts changed. */
|
||||
override fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring)
|
||||
}
|
||||
|
||||
/** Slow (Argon2), run off the main thread. */
|
||||
override fun unlock(secret: String): UnlockResult {
|
||||
val data = quickFile.readBytes()
|
||||
val inner = outer(data) ?: run { wipe(); return UnlockResult.Wiped }
|
||||
val key = innerKey(secret, data.copyOfRange(0, 16))
|
||||
val plain = try {
|
||||
gcm(Cipher.DECRYPT_MODE, key, inner)
|
||||
} catch (e: AEADBadTagException) {
|
||||
prefs.failedAttempts += 1
|
||||
val left = prefs.maxAttempts - prefs.failedAttempts
|
||||
if (left <= 0) {
|
||||
wipe()
|
||||
return UnlockResult.Wiped
|
||||
}
|
||||
return UnlockResult.Wrong(left)
|
||||
}
|
||||
prefs.failedAttempts = 0
|
||||
return UnlockResult.Ok(key, Keyring.decode(plain))
|
||||
}
|
||||
|
||||
private fun openWith(key: ByteArray): MutableMap<String, Unlocked>? {
|
||||
val inner = outer(quickFile.readBytes()) ?: return null
|
||||
return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
override fun wipe() {
|
||||
quickFile.delete()
|
||||
bioFile.delete()
|
||||
prefs.quickMethod = "none"
|
||||
prefs.bioMode = "off"
|
||||
prefs.failedAttempts = 0
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run {
|
||||
for (a in listOf(ALIAS_QUICK, ALIAS_BIO)) if (containsAlias(a)) deleteEntry(a)
|
||||
}
|
||||
}
|
||||
|
||||
override fun disableBio() {
|
||||
bioFile.delete()
|
||||
prefs.bioMode = "off"
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run { if (containsAlias(ALIAS_BIO)) deleteEntry(ALIAS_BIO) }
|
||||
}
|
||||
|
||||
/** Asks for a fingerprint/face (or screen lock) and stores the inner key under it. */
|
||||
override fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) {
|
||||
disableBio()
|
||||
val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) }
|
||||
prompt(cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c ->
|
||||
bioFile.writeBytes(c.iv + c.doFinal(innerKey))
|
||||
prefs.bioMode = mode
|
||||
done(null)
|
||||
}, onError = done)
|
||||
}
|
||||
|
||||
/** Fingerprint/face, then opens the keyring. Errors are "" when the user just cancelled. */
|
||||
override fun unlockBio(cancelText: String, onOk: (UnlockResult.Ok) -> Unit, onError: (String) -> Unit) {
|
||||
val data = bioFile.readBytes()
|
||||
val cipher = try {
|
||||
Cipher.getInstance(GCM).apply {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_BIO, prefs.bioMode), GCMParameterSpec(128, data, 0, 12))
|
||||
}
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
disableBio()
|
||||
onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.")
|
||||
return
|
||||
}
|
||||
prompt(cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c ->
|
||||
val key = c.doFinal(data, 12, data.size - 12)
|
||||
val keyring = openWith(key)
|
||||
if (keyring == null) onError("Biometric unlock no longer matches. Use your ${methodName(method)}.")
|
||||
else onOk(UnlockResult.Ok(key, keyring))
|
||||
}, onError = onError)
|
||||
}
|
||||
|
||||
private fun write(salt: ByteArray, key: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
val inner = gcm(Cipher.ENCRYPT_MODE, key, Keyring.encode(keyring))
|
||||
val outer = Cipher.getInstance(GCM).run {
|
||||
init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_QUICK, null))
|
||||
iv + doFinal(inner)
|
||||
}
|
||||
val tmp = File(quickFile.parentFile, "quick.tmp")
|
||||
tmp.writeBytes(salt + outer)
|
||||
check(tmp.renameTo(quickFile)) { "Could not save the app unlock" }
|
||||
}
|
||||
|
||||
/** Removes the hardware layer; null if the hardware key is gone. */
|
||||
private fun outer(data: ByteArray): ByteArray? = try {
|
||||
Cipher.getInstance(GCM).run {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_QUICK, null), GCMParameterSpec(128, data, 16, 12))
|
||||
doFinal(data, 28, data.size - 28)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun prompt(
|
||||
cipher: Cipher,
|
||||
mode: String,
|
||||
title: String,
|
||||
cancelText: String,
|
||||
onOk: (Cipher) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
try {
|
||||
onOk(result.cryptoObject!!.cipher!!)
|
||||
} catch (e: Exception) {
|
||||
onError("Biometric unlock failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
// Cancelling is not an error worth showing.
|
||||
val cancelled = errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_CANCELED
|
||||
onError(if (cancelled) "" else errString.toString())
|
||||
}
|
||||
}
|
||||
val info = BiometricPrompt.PromptInfo.Builder().setTitle(title).setSubtitle("NexoPass")
|
||||
if (mode == "device") {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG).setNegativeButtonText(cancelText)
|
||||
}
|
||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
||||
.authenticate(info.build(), BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
private fun innerKey(secret: String, salt: ByteArray) =
|
||||
NexoPass.derive(secret.toByteArray(), "nexopass:quick:" + salt.joinToString("") { "%02x".format(it) }, 32)
|
||||
|
||||
/** iv + ciphertext with a plain AES key. */
|
||||
private fun gcm(mode: Int, key: ByteArray, data: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(GCM)
|
||||
return if (mode == Cipher.ENCRYPT_MODE) {
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
|
||||
iv + c.doFinal(data)
|
||||
} else {
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, data, 0, 12))
|
||||
c.doFinal(data, 12, data.size - 12)
|
||||
}
|
||||
}
|
||||
|
||||
private fun keystoreKey(alias: String, bioMode: String?): SecretKey {
|
||||
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
if (bioMode != null) {
|
||||
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true)
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
val types = if (bioMode == "device") {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
||||
} else {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG
|
||||
}
|
||||
spec.setUserAuthenticationParameters(0, types)
|
||||
}
|
||||
}
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
.apply { init(spec.build()) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
/** Which biometric options this phone supports. */
|
||||
override fun bioOptions(): List<String> {
|
||||
val bm = BiometricManager.from(activity)
|
||||
return buildList {
|
||||
if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong")
|
||||
if (Build.VERSION.SDK_INT >= 30 &&
|
||||
bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
) add("device")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val GCM = "AES/GCM/NoPadding"
|
||||
private const val ALIAS_QUICK = "nexopass.quick"
|
||||
private const val ALIAS_BIO = "nexopass.bio"
|
||||
}
|
||||
}
|
||||
21
android/src/main/res/drawable/ic_launcher_background.xml
Normal file
21
android/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108">
|
||||
<item android:offset="0" android:color="#FF8B5CF6" />
|
||||
<item android:offset="1" android:color="#FF22D3EE" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
18
android/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
android/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:pivotX="54" android:pivotY="54" android:rotation="-45">
|
||||
<!-- Key bow: a ring. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42,40a14,14 0,1 1,0 28a14,14 0,1 1,0 -28zM42,48.5a5.5,5.5 0,1 0,0 11a5.5,5.5 0,1 0,0 -11z" />
|
||||
<!-- Shaft with two teeth. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M54,50.5h26v7h-4v7h-7v-7h-3v5h-6v-5h-6z" />
|
||||
</group>
|
||||
</vector>
|
||||
6
android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
android/src/main/res/values-night/colors.xml
Normal file
4
android/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FF07080F</color>
|
||||
</resources>
|
||||
8
android/src/main/res/values-night/themes.xml
Normal file
8
android/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android/src/main/res/values/colors.xml
Normal file
4
android/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FFF7F4FF</color>
|
||||
</resources>
|
||||
8
android/src/main/res/values/themes.xml
Normal file
8
android/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android/src/main/res/xml/data_extraction_rules.xml
Normal file
10
android/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Keep the vault out of cloud backups and device-to-device transfers. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Loading…
Reference in a new issue