add option to wipe eSIM

add option to limit the maximum number of failed password attempts
add option to disable code receiver
add app panic shortcut
This commit is contained in:
lucky 2022-01-03 02:33:01 +03:00
parent 547578d8e8
commit d64d290187
22 changed files with 316 additions and 110 deletions

View File

@ -8,11 +8,14 @@ Lock device and wipe data on panic trigger.
<img src="https://user-images.githubusercontent.com/53379023/147702625-7aef3d46-b819-40df-86e9-c3de418f2b58.png" width="30%" height="30%">
You can use [PanicKit](https://guardianproject.info/code/panickit/), Tile or send broadcast message
with authentication code. On trigger, using
You can use [PanicKit](https://guardianproject.info/code/panickit/), tile, shortcut or send
broadcast message with authentication code. On trigger, using
[Device Administration API](https://developer.android.com/guide/topics/admin/device-admin), it
locks device and optionally runs wipe.
Also you can limit the maximum number of failed password attempts. After the limit exceeded
device will be wiped.
## Example
Broadcast message:

View File

@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 1.1.x | :white_check_mark: |
| < 1.1 | :x: |
| 1.2.x | :white_check_mark: |
| < 1.2 | :x: |
## Reporting a Vulnerability

View File

@ -10,8 +10,8 @@ android {
applicationId "me.lucky.wasted"
minSdk 23
targetSdk 31
versionCode 10
versionName "1.1.3"
versionCode 12
versionName "1.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -65,6 +65,17 @@
</intent-filter>
</activity>
<activity
android:name=".ShortcutActivity"
android:noHistory="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="me.lucky.wasted.action.TRIGGER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".QSTileService"
android:icon="@android:drawable/ic_lock_lock"

View File

@ -6,20 +6,25 @@ import android.content.Intent
class CodeReceiver : BroadcastReceiver() {
companion object {
private const val TRIGGER = "me.lucky.wasted.action.TRIGGER"
const val KEY = "code"
const val ACTION = "me.lucky.wasted.action.TRIGGER"
fun panic(context: Context, intent: Intent) {
val prefs = Preferences(context)
val code = prefs.code
if (!prefs.isServiceEnabled ||
code == "" ||
intent.action != ACTION ||
intent.getStringExtra(KEY) != code) return
val admin = DeviceAdminManager(context)
try {
admin.lockNow()
if (prefs.isWipeData) admin.wipeData()
} catch (exc: SecurityException) {}
}
}
override fun onReceive(context: Context, intent: Intent) {
val prefs by lazy { Preferences(context) }
val code = prefs.code
if (!prefs.isServiceEnabled ||
code == "" ||
intent.action != TRIGGER ||
intent.getStringExtra("code") != code) return
val admin = DeviceAdmin(context)
try {
admin.dpm.lockNow()
if (prefs.doWipe) admin.wipeData()
} catch (exc: SecurityException) {}
panic(context, intent)
}
}

View File

@ -1,21 +0,0 @@
package me.lucky.wasted
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.os.Build
class DeviceAdmin(ctx: Context) {
val dpm by lazy {
ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
}
val deviceAdmin by lazy { ComponentName(ctx, DeviceAdminReceiver::class.java) }
fun remove() = dpm.removeActiveAdmin(deviceAdmin)
fun isActive(): Boolean = dpm.isAdminActive(deviceAdmin)
fun wipeData() {
dpm.wipeData(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
DevicePolicyManager.WIPE_SILENTLY else 0)
}
}

View File

@ -0,0 +1,40 @@
package me.lucky.wasted
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
class DeviceAdminManager(private val ctx: Context) {
private val dpm by lazy {
ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
}
private val deviceAdmin by lazy { ComponentName(ctx, DeviceAdminReceiver::class.java) }
private val prefs by lazy { Preferences(ctx) }
fun remove() = dpm.removeActiveAdmin(deviceAdmin)
fun isActive(): Boolean = dpm.isAdminActive(deviceAdmin)
fun getCurrentFailedPasswordAttempts(): Int = dpm.currentFailedPasswordAttempts
fun lockNow() = dpm.lockNow()
fun wipeData() {
var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
flags = flags.or(DevicePolicyManager.WIPE_SILENTLY)
}
if (prefs.isWipeESIM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
flags = flags.or(DevicePolicyManager.WIPE_EUICC)
}
dpm.wipeData(flags)
}
fun makeRequestIntent(): Intent {
return Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, deviceAdmin)
.putExtra(
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
ctx.getString(R.string.device_admin_description),
)
}
}

View File

@ -1,5 +1,30 @@
package me.lucky.wasted
import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.UserHandle
import androidx.annotation.RequiresApi
class DeviceAdminReceiver : DeviceAdminReceiver()
class DeviceAdminReceiver : DeviceAdminReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onPasswordFailed(context: Context, intent: Intent, user: UserHandle) {
super.onPasswordFailed(context, intent, user)
onPasswordFailedInternal(context)
}
override fun onPasswordFailed(context: Context, intent: Intent) {
super.onPasswordFailed(context, intent)
onPasswordFailedInternal(context)
}
private fun onPasswordFailedInternal(ctx: Context) {
val prefs = Preferences(ctx)
if (!prefs.isServiceEnabled || prefs.maxFailedPasswordAttempts == 0) return
val admin = DeviceAdminManager(ctx)
if (admin.getCurrentFailedPasswordAttempts() >= prefs.maxFailedPasswordAttempts)
admin.wipeData()
}
}

View File

@ -1,11 +1,12 @@
package me.lucky.wasted
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.SeekBar
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
@ -17,7 +18,8 @@ open class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val prefs by lazy { Preferences(this) }
private val admin by lazy { DeviceAdmin(this) }
private val admin by lazy { DeviceAdminManager(this) }
private val shortcut by lazy { ShortcutManager(this) }
private val requestAdminPolicy =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@ -51,23 +53,54 @@ open class MainActivity : AppCompatActivity() {
private fun init() {
if (prefs.code == "") prefs.code = makeCode()
updateCodeColorState()
binding.apply {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) wipeESIM.visibility = View.GONE
code.text = prefs.code
wipeDataCheckBox.isChecked = prefs.doWipe
wipeData.isChecked = prefs.isWipeData
wipeESIM.isChecked = prefs.isWipeESIM
wipeESIM.isEnabled = wipeData.isChecked
maxFailedPasswordAttempts.progress = prefs.maxFailedPasswordAttempts
toggle.isChecked = prefs.isServiceEnabled
}
}
private fun setup() {
binding.apply {
code.setOnClickListener {
prefs.isCodeEnabled = !prefs.isCodeEnabled
updateCodeColorState()
setCodeReceiverState(
this@MainActivity,
prefs.isServiceEnabled && prefs.isCodeEnabled,
)
}
code.setOnLongClickListener {
prefs.code = makeCode()
code.text = prefs.code
true
}
wipeDataCheckBox.setOnCheckedChangeListener { _, isChecked ->
prefs.doWipe = isChecked
wipeData.setOnCheckedChangeListener { _, isChecked ->
prefs.isWipeData = isChecked
wipeESIM.isEnabled = isChecked
}
wipeESIM.setOnCheckedChangeListener { _, isChecked ->
prefs.isWipeESIM = isChecked
}
maxFailedPasswordAttempts.setOnSeekBarChangeListener(
object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar?,
progress: Int,
fromUser: Boolean,
) {
prefs.maxFailedPasswordAttempts = progress
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
toggle.setOnCheckedChangeListener { _, isChecked ->
when (isChecked) {
true -> if (!admin.isActive()) requestAdmin() else setOn()
@ -77,31 +110,29 @@ open class MainActivity : AppCompatActivity() {
}
}
private fun updateCodeColorState() {
binding.code.setBackgroundColor(getColor(
if (prefs.isCodeEnabled) R.color.code_receiver_on else R.color.code_receiver_off
))
}
private fun setOn() {
prefs.isServiceEnabled = true
setControlReceiverState(this, true)
setCodeReceiverState(this, prefs.isCodeEnabled)
shortcut.push()
}
private fun setOff() {
admin.remove()
setControlReceiverState(this, false)
setCodeReceiverState(this, false)
shortcut.remove()
prefs.isServiceEnabled = false
}
private fun requestAdmin() {
val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply {
putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, admin.deviceAdmin)
putExtra(
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
getString(R.string.device_admin_description),
)
}
requestAdminPolicy.launch(intent)
}
private fun requestAdmin() = requestAdminPolicy.launch(admin.makeRequestIntent())
private fun makeCode(): String = UUID.randomUUID().toString()
private fun setControlReceiverState(ctx: Context, value: Boolean) {
private fun setCodeReceiverState(ctx: Context, value: Boolean) {
ctx.packageManager.setComponentEnabledSetting(
ComponentName(ctx, CodeReceiver::class.java),
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else

View File

@ -31,18 +31,17 @@ class PanicConnectionActivity : MainActivity() {
} catch (exc: PackageManager.NameNotFoundException) {}
}
AlertDialog.Builder(this).apply {
setTitle(getString(R.string.panic_app_dialog_title))
setMessage(String.format(getString(R.string.panic_app_dialog_message), app))
setNegativeButton(R.string.allow) { _, _ ->
AlertDialog.Builder(this)
.setTitle(getString(R.string.panic_app_dialog_title))
.setMessage(String.format(getString(R.string.panic_app_dialog_message), app))
.setNegativeButton(R.string.allow) { _, _ ->
PanicResponder.setTriggerPackageName(this@PanicConnectionActivity)
setResult(RESULT_OK)
}
setPositiveButton(R.string.cancel) { _, _ ->
.setPositiveButton(R.string.cancel) { _, _ ->
setResult(RESULT_CANCELED)
finish()
}
show()
}
.show()
}
}

View File

@ -8,19 +8,19 @@ import info.guardianproject.panic.PanicResponder
class PanicResponderActivity : AppCompatActivity() {
private val prefs by lazy { Preferences(this) }
private val admin by lazy { DeviceAdmin(this) }
private val admin by lazy { DeviceAdminManager(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!Panic.isTriggerIntent(intent) || !prefs.isServiceEnabled) {
finish()
finishAndRemoveTask()
return
}
try {
admin.dpm.lockNow()
admin.lockNow()
if (PanicResponder.receivedTriggerFromConnectedApp(this) &&
prefs.doWipe) admin.wipeData()
prefs.isWipeData) admin.wipeData()
} catch (exc: SecurityException) {}
finish()
finishAndRemoveTask()
}
}

View File

@ -9,7 +9,10 @@ class Preferences(ctx: Context) {
companion object {
private const val SERVICE_ENABLED = "service_enabled"
private const val CODE = "code"
private const val DO_WIPE = "do_wipe"
private const val CODE_ENABLED = "code_enabled"
private const val WIPE_DATA = "wipe_data"
private const val WIPE_ESIM = "wipe_esim"
private const val MAX_FAILED_PASSWORD_ATTEMPTS = "max_failed_password_attempts"
}
private val mk = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
@ -29,7 +32,19 @@ class Preferences(ctx: Context) {
get() = prefs.getString(CODE, "")
set(value) = prefs.edit { putString(CODE, value) }
var doWipe: Boolean
get() = prefs.getBoolean(DO_WIPE, false)
set(value) = prefs.edit { putBoolean(DO_WIPE, value) }
var isCodeEnabled: Boolean
get() = prefs.getBoolean(CODE_ENABLED, false)
set(value) = prefs.edit { putBoolean(CODE_ENABLED, value) }
var isWipeData: Boolean
get() = prefs.getBoolean(WIPE_DATA, false)
set(value) = prefs.edit { putBoolean(WIPE_DATA, value) }
var isWipeESIM: Boolean
get() = prefs.getBoolean(WIPE_ESIM, false)
set(value) = prefs.edit { putBoolean(WIPE_ESIM, value) }
var maxFailedPasswordAttempts: Int
get() = prefs.getInt(MAX_FAILED_PASSWORD_ATTEMPTS, 0)
set(value) = prefs.edit { putInt(MAX_FAILED_PASSWORD_ATTEMPTS, value) }
}

View File

@ -11,7 +11,7 @@ import kotlin.concurrent.timerTask
@RequiresApi(Build.VERSION_CODES.N)
class QSTileService : TileService() {
private val prefs by lazy { Preferences(this) }
private val admin by lazy { DeviceAdmin(this) }
private val admin by lazy { DeviceAdminManager(this) }
private val counter = AtomicInteger(0)
private var timer: Timer? = null
@ -26,9 +26,9 @@ class QSTileService : TileService() {
override fun onClick() {
super.onClick()
if (!prefs.isServiceEnabled) return
if (!prefs.doWipe) {
if (!prefs.isWipeData) {
try {
admin.dpm.lockNow()
admin.lockNow()
} catch (exc: SecurityException) {}
return
}
@ -39,7 +39,7 @@ class QSTileService : TileService() {
timer = Timer()
timer?.schedule(timerTask {
try {
admin.dpm.lockNow()
admin.lockNow()
admin.wipeData()
} catch (exc: SecurityException) {}
}, 2000)
@ -53,9 +53,7 @@ class QSTileService : TileService() {
}
private fun update(tileState: Int) {
qsTile.apply {
state = tileState
updateTile()
}
qsTile.state = tileState
qsTile.updateTile()
}
}

View File

@ -0,0 +1,12 @@
package me.lucky.wasted
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class ShortcutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CodeReceiver.panic(this, intent)
finishAndRemoveTask()
}
}

View File

@ -0,0 +1,32 @@
package me.lucky.wasted
import android.content.Context
import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
class ShortcutManager(private val ctx: Context) {
companion object {
private const val SHORTCUT_ID = "panic"
}
private val prefs by lazy { Preferences(ctx) }
fun push() {
ShortcutManagerCompat.pushDynamicShortcut(
ctx,
ShortcutInfoCompat.Builder(ctx, SHORTCUT_ID)
.setShortLabel(ctx.getString(R.string.shortcut_label))
.setIcon(IconCompat.createWithResource(ctx, android.R.drawable.ic_delete))
.setIntent(
Intent(CodeReceiver.ACTION)
.setClass(ctx, ShortcutActivity::class.java)
.putExtra(CodeReceiver.KEY, prefs.code)
)
.build(),
)
}
fun remove() = ShortcutManagerCompat.removeDynamicShortcuts(ctx, arrayListOf(SHORTCUT_ID))
}

View File

@ -16,20 +16,75 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/code"
<ScrollView
android:id="@+id/scrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="#FFD600"
android:padding="16dp"
android:textAlignment="center"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold"
android:layout_height="0dp"
android:layout_marginVertical="16dp"
app:layout_constraintBottom_toTopOf="@+id/toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/description" />
app:layout_constraintTop_toBottomOf="@+id/description">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/code_receiver_off"
android:padding="16dp"
android:textAlignment="center"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
<CheckBox
android:id="@+id/wipeData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:layoutDirection="rtl"
android:text="@string/wipe_data_check_box"
android:textSize="16sp" />
<CheckBox
android:id="@+id/wipeESIM"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:layoutDirection="rtl"
android:text="@string/wipe_esim_check_box"
android:textSize="16sp" />
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="16dp"
android:background="?android:attr/listDivider" />
<TextView
android:id="@+id/description2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/max_failed_password_attempts_description" />
<SeekBar
android:id="@+id/maxFailedPasswordAttempts"
style="@style/Widget.AppCompat.SeekBar.Discrete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:max="10"
android:progress="0" />
</LinearLayout>
</ScrollView>
<ToggleButton
android:id="@+id/toggle"
@ -41,16 +96,4 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<CheckBox
android:id="@+id/wipeDataCheckBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:layoutDirection="rtl"
android:text="@string/wipe_data_check_box"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/code" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -7,4 +7,6 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="code_receiver_on">#FFD600</color>
<color name="code_receiver_off">#E13741</color>
</resources>

View File

@ -1,14 +1,17 @@
<resources>
<string name="app_name">Wasted</string>
<string name="description">Turn on Wasted to lock device on panic trigger. You can use PanicKit, Tile or send broadcast message with this authentication code.</string>
<string name="description">Turn on Wasted to lock device on panic trigger. You can use PanicKit, tile, shortcut or send broadcast message with this authentication code.</string>
<string name="device_admin_label">Wasted</string>
<string name="device_admin_description">Allow Wasted to lock device and optionally wipe data on panic trigger</string>
<string name="service_unavailable_toast">Admin service unavailable</string>
<string name="wipe_data_check_box">Wipe data</string>
<string name="panic_app_dialog_title">Confirm Panic App</string>
<string name="wipe_esim_check_box">Wipe eSIM</string>
<string name="panic_app_dialog_title">Confirm panic app</string>
<string name="panic_app_dialog_message">Are you sure that you want to allow %1$s to trigger destructive panic actions\?</string>
<string name="panic_app_unknown_app">an unknown app</string>
<string name="allow">Allow</string>
<string name="cancel">Cancel</string>
<string name="tile_label">Unlock device</string>
<string name="shortcut_label">Panic</string>
<string name="max_failed_password_attempts_description">Also you can limit the maximum number of failed password attempts. After the limit exceeded device will be wiped.</string>
</resources>

View File

@ -2,6 +2,7 @@
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<force-lock />
<watch-login />
<wipe-data />
</uses-policies>
</device-admin>

View File

@ -0,0 +1,4 @@
add option to wipe eSIM
add option to limit the maximum number of failed password attempts
add option to disable code receiver
add app panic shortcut

View File

@ -1,4 +1,7 @@
Lock device and wipe data on panic trigger.
You can use PanicKit, Tile or send broadcast message with authentication code.
You can use PanicKit, tile, shortcut or send broadcast message with authentication code.
On trigger, using Device Administration API, it locks device and optionally runs wipe.
Also you can limit the maximum number of failed password attempts.
After the limit exceeded device will be wiped.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 105 KiB