diff --git a/AndroidManifest.xml b/AndroidManifest.xml --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -22,6 +22,7 @@ + @@ -225,6 +226,16 @@ android:value="org.kde.kdeconnect.UserInterface.PluginSettingsActivity" /> + + + + + + diff --git a/res/drawable/ic_fingerprint_black_24dp.xml b/res/drawable/ic_fingerprint_black_24dp.xml new file mode 100644 --- /dev/null +++ b/res/drawable/ic_fingerprint_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/values/strings.xml b/res/values/strings.xml --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -242,4 +242,17 @@ Dark theme + Tile service for unlocking from your phone or tablet + Unlock Computer + Unlock using android fingerprint sensor + Touch Sensor + Touch the fingerprint sensor to unlock your computer. KDEConnect will unlock the paired computer after successful identification + Authentication successful + Authentication failed + KDEconnect is disconnected + Security Exception. Try Again + UnLock Plugin + Unlock remote device from android + Lock + diff --git a/src/org/kde/kdeconnect/Plugins/LockPlugin/LockHelper.java b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockHelper.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockHelper.java @@ -0,0 +1,228 @@ +package org.kde.kdeconnect.Plugins.LockPlugin; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; +import android.os.CancellationSignal; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyPermanentlyInvalidatedException; +import android.security.keystore.KeyProperties; +import android.support.annotation.RequiresApi; +import android.util.Log; + +import org.kde.kdeconnect_tp.R; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.UUID; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LockHelper extends FingerprintManager.AuthenticationCallback { + + public interface LockCallback { + void onSuccess(); + void onFailure(); + void onError(int errCode, CharSequence errString); + } + + private static final String KEY_NAME = UUID.randomUUID().toString(); + private static final String TAG = LockHelper.class.getSimpleName(); + + private static volatile LockHelper mLockHelper; + private ArrayList callbacks = new ArrayList<>(); + + public void addLockCallback(LockCallback newCallback) { + callbacks.add(newCallback); + } + + public void removeLockCallback(LockCallback oldCallback) { + callbacks.remove(oldCallback); + } + + + private Dialog mDialog; + + // fp stuff + private KeyStore mKeyStore; + private Cipher mCipher; + private CancellationSignal mCancellationSignal; + + + private LockHelper(Context context) { + if (mLockHelper != null) { + throw new RuntimeException("Use getInstance()"); + } + + this.mDialog = new AlertDialog.Builder(context) + .setTitle(R.string.title_fp_unlock_diag) + .setMessage(R.string.mesg_fp_unlock_diag) + .setIcon(R.drawable.ic_fingerprint_black_24dp) + .setOnDismissListener(dialog -> onAuthenticationFailed()) + .setNeutralButton(R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + onAuthenticationFailed(); + }) + .create(); + } + + public synchronized static LockHelper getInstance(Context context) { + if(mLockHelper == null) { + mLockHelper = new LockHelper(context); + } + return mLockHelper; + } + + boolean shouldAuth() { + return !mDialog.isShowing(); + } + + public Dialog getDialog() { + return mDialog; + } + + private boolean generateKey() { + mKeyStore = null; + KeyGenerator keyGenerator; + + try { + mKeyStore = KeyStore.getInstance("AndroidKeyStore"); + keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); + } catch (NoSuchAlgorithmException | + NoSuchProviderException e) { + return false; + } catch (KeyStoreException e) { + return false; + } + + try { + mKeyStore.load(null); + keyGenerator.init(new + KeyGenParameterSpec.Builder(KEY_NAME, + KeyProperties.PURPOSE_ENCRYPT | + KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setUserAuthenticationRequired(true) + .setEncryptionPaddings( + KeyProperties.ENCRYPTION_PADDING_PKCS7) + .build()); + keyGenerator.generateKey(); + + return true; + } catch (NoSuchAlgorithmException + | InvalidAlgorithmParameterException + | CertificateException + | IOException e) { + return false; + } + } + + private boolean cipherInit() { + + if (!generateKey()) { + onAuthenticationFailed(); + return false; + } + + try { + mCipher = Cipher.getInstance( + KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + KeyProperties.ENCRYPTION_PADDING_PKCS7); + } catch (NoSuchAlgorithmException | + NoSuchPaddingException e) { + onAuthenticationFailed(); + return false; + } + + try { + mKeyStore.load(null); + SecretKey key = (SecretKey) mKeyStore.getKey(KEY_NAME, null); + mCipher.init(Cipher.ENCRYPT_MODE, key); + return true; + } catch (KeyPermanentlyInvalidatedException e) { + onAuthenticationFailed(); + return false; + } catch (KeyStoreException | CertificateException + | UnrecoverableKeyException | IOException + | NoSuchAlgorithmException | InvalidKeyException e) { + onAuthenticationFailed(); + return false; + } + } + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + mDialog.dismiss(); + Log.e(TAG, "onAuthenticationError: " + errString); + for (LockCallback c : + callbacks) { + c.onError(errorCode, errString); + } + onAuthenticationFailed(); + } + + @Override + public void onAuthenticationHelp(int helpCode, CharSequence helpString) { + super.onAuthenticationHelp(helpCode, helpString); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + mDialog.setOnDismissListener(null); + mDialog.dismiss(); + for (LockCallback c : + callbacks) { + c.onSuccess(); + } + } + + @Override + public void onAuthenticationFailed() { + super.onAuthenticationFailed(); + mDialog.dismiss(); + for (LockCallback c : + callbacks) { + c.onFailure(); + } + if (mCancellationSignal != null) { + mCancellationSignal.cancel(); + mCancellationSignal = null; + } + } + + public void pleaseAuthenticate(FingerprintManager manager) { + if(!cipherInit()) { + onAuthenticationFailed(); + return; + } + mCancellationSignal = new CancellationSignal(); + try { + manager.authenticate(new FingerprintManager.CryptoObject(mCipher), + mCancellationSignal, 0, this, null); + } catch (SecurityException e) { + mDialog.dismiss(); + for (LockCallback c : + callbacks) { + c.onError(-32, e.getMessage()); + } + Log.e(TAG, "pleaseAuthenticate: ", e); + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/LockPlugin/LockPlugin.java b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockPlugin.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockPlugin.java @@ -0,0 +1,65 @@ +package org.kde.kdeconnect.Plugins.LockPlugin; + +import org.kde.kdeconnect.NetworkPacket; +import org.kde.kdeconnect.Plugins.Plugin; +import org.kde.kdeconnect_tp.R; + + +public class LockPlugin extends Plugin { + + public final static String PACKET_TYPE_LOCK = "kdeconnect.lock"; + private final static String TAG = LockPlugin.class.getSimpleName(); + + @Override + public String getDisplayName() { + return context.getResources().getString(R.string.pref_plugin_lock); + } + + @Override + public String getDescription() { + return context.getResources().getString(R.string.pref_plugin_lock_desc); + } + + public void unlockDevice() { + NetworkPacket np = new NetworkPacket(PACKET_TYPE_LOCK); + device.sendPacket(np); + } + + @Override + public boolean onPacketReceived(NetworkPacket np) { + return false; + } + + @Override + public String getActionName() { + return context.getString(R.string.lock); + } + + /* Should it be available via UI? + @Override + public void startMainActivity(Activity activity) { + unlockDevice(); + } + + @Override + public boolean hasMainActivity() { + return true; + } + + @Override + public boolean displayInContextMenu() { + return false; + } + */ + + @Override + public String[] getSupportedPacketTypes() { + return new String[]{PACKET_TYPE_LOCK}; + } + + @Override + public String[] getOutgoingPacketTypes() { + return new String[]{PACKET_TYPE_LOCK}; + } + +} diff --git a/src/org/kde/kdeconnect/Plugins/LockPlugin/LockTileService.java b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockTileService.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/LockPlugin/LockTileService.java @@ -0,0 +1,126 @@ +package org.kde.kdeconnect.Plugins.LockPlugin; + +import android.content.Context; +import android.graphics.drawable.Icon; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; +import android.support.annotation.RequiresApi; +import android.widget.Toast; + +import org.json.JSONException; +import org.json.JSONObject; +import org.kde.kdeconnect.BackgroundService; +import org.kde.kdeconnect.Device; +import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin; +import org.kde.kdeconnect_tp.R; + +import java.util.Locale; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LockTileService extends TileService implements LockHelper.LockCallback { + + private LockHelper mLockHelper; + private static final String STATE_SELECTED_DEVICE = "selected_device"; + private final static String TAG = LockTileService.class.getSimpleName(); + + @Override + public void onClick() { + super.onClick(); + if(isLocked()) { + unlockAndRun(this::unlockComputer); + } else { + if (mLockHelper.shouldAuth()) { + showDialog(mLockHelper.getDialog()); + mLockHelper.pleaseAuthenticate((FingerprintManager) + getSystemService(FINGERPRINT_SERVICE)); + } + } + } + + @Override + public void onStartListening() { + Tile tile = getQsTile(); + tile.setIcon(Icon.createWithResource(this, R.drawable.ic_fingerprint_black_24dp)); + tile.setLabel(getString(R.string.label_qs_unlock)); + tile.setContentDescription(getString(R.string.desc_qs_unlock)); + tile.setState(Tile.STATE_ACTIVE); + tile.updateTile(); + mLockHelper = LockHelper.getInstance(this); + mLockHelper.addLockCallback(this); + } + + @Override + public void onSuccess() { + //TODO: are toasts really necessary? they ugly + if(unlockComputer(1)) { + Toast.makeText(this, "Authenticated Successfully", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(LockTileService.this, R.string.mesg_app_discon, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure() { + Toast.makeText(this, "Authentication Failed", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onError(int errCode, CharSequence errString) { + Toast.makeText(this, String.format(Locale.getDefault(), "%d : %s", errCode, errString), + Toast.LENGTH_SHORT).show(); + } + + boolean bool = false; + + private boolean unlockComputer() { + BackgroundService.RunCommand(this, service -> { + Device device = service.getDevice(getSharedPreferences(STATE_SELECTED_DEVICE, Context.MODE_PRIVATE) + .getString(STATE_SELECTED_DEVICE, null)); + LockPlugin p = device.getPlugin(LockPlugin.class); + if (p == null) { + bool = false; + return; + } + p.unlockDevice(); + bool = true; + }); + return bool; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mLockHelper.removeLockCallback(this); + } + + private boolean unlockComputer(int a) { + BackgroundService.RunCommand(this, service -> { + //TODO: don't piggyback on runcommand + String deviceId = getSharedPreferences(STATE_SELECTED_DEVICE, + Context.MODE_PRIVATE) + .getString(STATE_SELECTED_DEVICE, null); + Device device = service.getDevice(deviceId); + RunCommandPlugin plugin = device.getPlugin(RunCommandPlugin.class); + if (plugin == null) { + bool = false; + return; + } + for (JSONObject obj : plugin.getCommandList()) { + try { + // ideally there should only be one command named "unlock" + // all of them are executed though + // command: loginctl unlock-session + if(obj.getString("name").equals("unlock")) { + plugin.runCommand(obj.getString("key")); + } + } catch (JSONException e) { + e.printStackTrace(); + } + } + bool = true; + }); + return bool; + } +}