diff --git a/app/src/main/java/com/termux/api/TermuxApiReceiver.java b/app/src/main/java/com/termux/api/TermuxApiReceiver.java index 6efaafe16..95f016687 100644 --- a/app/src/main/java/com/termux/api/TermuxApiReceiver.java +++ b/app/src/main/java/com/termux/api/TermuxApiReceiver.java @@ -146,7 +146,7 @@ private void doWork(Context context, Intent intent) { JobSchedulerAPI.onReceive(this, context, intent); break; case "Keystore": - KeystoreAPI.onReceive(this, intent); + KeystoreAPI.onReceive(this, context, intent); break; case "Location": if (TermuxApiPermissionActivity.checkAndRequestPermissions(context, intent, Manifest.permission.ACCESS_FINE_LOCATION)) { diff --git a/app/src/main/java/com/termux/api/apis/FingerprintAPI.java b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java index 233f2044f..92780117c 100644 --- a/app/src/main/java/com/termux/api/apis/FingerprintAPI.java +++ b/app/src/main/java/com/termux/api/apis/FingerprintAPI.java @@ -2,6 +2,7 @@ import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -9,6 +10,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.biometric.BiometricManager; import androidx.biometric.BiometricPrompt; import androidx.core.hardware.fingerprint.FingerprintManagerCompat; import androidx.fragment.app.FragmentActivity; @@ -20,6 +22,10 @@ import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; @@ -32,8 +38,7 @@ public class FingerprintAPI { protected static final String KEY_NAME = "TermuxFingerprintAPIKey"; protected static final String KEYSTORE_NAME = "AndroidKeyStore"; - // milliseconds to wait before canceling - protected static final int SENSOR_TIMEOUT = 10000; + protected static int SENSOR_TIMEOUT; // maximum authentication attempts before locked out protected static final int MAX_ATTEMPTS = 5; @@ -53,22 +58,26 @@ public class FingerprintAPI { protected static final String AUTH_RESULT_FAILURE = "AUTH_RESULT_FAILURE"; protected static final String AUTH_RESULT_UNKNOWN = "AUTH_RESULT_UNKNOWN"; - - // store result of fingerprint initialization / authentication protected static FingerprintResult fingerprintResult = new FingerprintResult(); // have we posted our result back? protected static boolean postedResult = false; - + protected static boolean timedOut = false; private static final String LOG_TAG = "FingerprintAPI"; + private static final Lock lock = new ReentrantLock(); + private static final Condition condition = lock.newCondition(); + protected static final String EXTRA_LOCK_ACTION = "EXTRA_LOCK_ACTION"; + /** * Handles setup of fingerprint sensor and writes Fingerprint result to console */ public static void onReceive(final Context context, final Intent intent) { Logger.logDebug(LOG_TAG, "onReceive"); + SENSOR_TIMEOUT = intent.getIntExtra("authenticationTimeout", 10); + if (SENSOR_TIMEOUT != -1) SENSOR_TIMEOUT *= 1000; resetFingerprintResult(); @@ -79,6 +88,22 @@ public static void onReceive(final Context context, final Intent intent) { fingerprintIntent.putExtras(intent.getExtras()); fingerprintIntent.setFlags(FLAG_ACTIVITY_NEW_TASK); context.startActivity(fingerprintIntent); + + if (intent.getBooleanExtra(EXTRA_LOCK_ACTION, false)) { + lock.lock(); + try { + if (SENSOR_TIMEOUT != -1) { + if (!condition.await(SENSOR_TIMEOUT+5000, TimeUnit.MILLISECONDS)) { + timedOut = true; + Logger.logDebug(LOG_TAG, "Lock timed out"); + } + } else condition.await(); + } catch (InterruptedException e) { + // If interrupted, nothing currently + } finally { + lock.unlock(); + } + } } else { postFingerprintResult(context, intent, fingerprintResult); } @@ -88,28 +113,38 @@ public static void onReceive(final Context context, final Intent intent) { * Writes the result of our fingerprint result to the console */ protected static void postFingerprintResult(Context context, Intent intent, final FingerprintResult result) { - ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { - @Override - public void writeJson(JsonWriter out) throws Exception { - out.beginObject(); + if (intent.getBooleanExtra(EXTRA_LOCK_ACTION, false)) { + lock.lock(); + try { + condition.signalAll(); + } finally { + lock.unlock(); + if (context instanceof Activity) ((Activity) context).finish(); + } + } else { + ResultReturner.returnData(context, intent, new ResultReturner.ResultJsonWriter() { + @Override + public void writeJson(JsonWriter out) throws Exception { + out.beginObject(); - out.name("errors"); - out.beginArray(); + out.name("errors"); + out.beginArray(); - for (String error : result.errors) { - out.value(error); - } - out.endArray(); + for (String error : result.errors) { + out.value(error); + } + out.endArray(); - out.name("failed_attempts").value(result.failedAttempts); - out.name("auth_result").value(result.authResult); - out.endObject(); + out.name("failed_attempts").value(result.failedAttempts); + out.name("auth_result").value(result.authResult); + out.endObject(); - out.flush(); - out.close(); - postedResult = true; - } - }); + out.flush(); + out.close(); + postedResult = true; + } + }); + } } /** @@ -161,37 +196,40 @@ protected void handleFingerprint() { * Handles authentication callback from our fingerprint sensor */ protected static void authenticateWithFingerprint(final FragmentActivity context, final Intent intent, final Executor executor) { - BiometricPrompt biometricPrompt = new BiometricPrompt(context, executor, new BiometricPrompt.AuthenticationCallback() { - @Override - public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { - if (errorCode == BiometricPrompt.ERROR_LOCKOUT) { - appendFingerprintError(ERROR_LOCKOUT); - - // first time locked out, subsequent auth attempts will fail immediately for a bit - if (fingerprintResult.failedAttempts >= MAX_ATTEMPTS) { - appendFingerprintError(ERROR_TOO_MANY_FAILED_ATTEMPTS); + BiometricPrompt biometricPrompt = new BiometricPrompt(context, executor, + new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + if (errorCode == BiometricPrompt.ERROR_LOCKOUT) { + appendFingerprintError(ERROR_LOCKOUT); + + // first time locked out, subsequent auth attempts will fail immediately for a bit + if (fingerprintResult.failedAttempts >= MAX_ATTEMPTS) { + appendFingerprintError(ERROR_TOO_MANY_FAILED_ATTEMPTS); + } + } + setAuthResult(AUTH_RESULT_FAILURE); + if (timedOut) timedOut = false; + else postFingerprintResult(context, intent, fingerprintResult); + Logger.logError(LOG_TAG, errString.toString()); } - } - setAuthResult(AUTH_RESULT_FAILURE); - postFingerprintResult(context, intent, fingerprintResult); - Logger.logError(LOG_TAG, errString.toString()); - } - @Override - public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { - setAuthResult(AUTH_RESULT_SUCCESS); - postFingerprintResult(context, intent, fingerprintResult); - } + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + setAuthResult(AUTH_RESULT_SUCCESS); + postFingerprintResult(context, intent, fingerprintResult); + } - @Override - public void onAuthenticationFailed() { - addFailedAttempt(); - } - }); + @Override + public void onAuthenticationFailed() { + addFailedAttempt(); + } + }); + boolean[] auths = intent.getBooleanArrayExtra("auths"); BiometricPrompt.PromptInfo.Builder builder = new BiometricPrompt.PromptInfo.Builder(); - builder.setTitle(intent.hasExtra("title") ? intent.getStringExtra("title") : "Authenticate"); - builder.setNegativeButtonText(intent.hasExtra("cancel") ? intent.getStringExtra("cancel") : "Cancel"); + builder.setTitle(intent.hasExtra("title") ? + intent.getStringExtra("title") : "Authenticate"); if (intent.hasExtra("description")) { builder.setDescription(intent.getStringExtra("description")); } @@ -199,10 +237,23 @@ public void onAuthenticationFailed() { builder.setSubtitle(intent.getStringExtra("subtitle")); } + if (auths == null || !auths[0] || Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + builder.setNegativeButtonText(intent.hasExtra("cancel") ? + intent.getStringExtra("cancel") : "Cancel"); + builder.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG); + } else if (!auths[1]) { + builder.setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL); //Can't test yet + } else { + builder.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | + BiometricManager.Authenticators.DEVICE_CREDENTIAL); + } + // listen to fingerprint sensor biometricPrompt.authenticate(builder.build()); - addSensorTimeout(context, intent, biometricPrompt); + if (SENSOR_TIMEOUT != -1) { + addSensorTimeout(context, intent, biometricPrompt); + } } /** @@ -215,7 +266,9 @@ protected static void addSensorTimeout(final Context context, final Intent inten if (!postedResult) { appendFingerprintError(ERROR_TIMEOUT); biometricPrompt.cancelAuthentication(); - postFingerprintResult(context, intent, fingerprintResult); + if (!intent.getBooleanExtra(EXTRA_LOCK_ACTION, false)) { + postFingerprintResult(context, intent, fingerprintResult); + } } }, SENSOR_TIMEOUT); } diff --git a/app/src/main/java/com/termux/api/apis/KeystoreAPI.java b/app/src/main/java/com/termux/api/apis/KeystoreAPI.java index ffe008dd4..4396ac4e5 100644 --- a/app/src/main/java/com/termux/api/apis/KeystoreAPI.java +++ b/app/src/main/java/com/termux/api/apis/KeystoreAPI.java @@ -1,41 +1,67 @@ package com.termux.api.apis; import android.annotation.SuppressLint; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.os.Build; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyInfo; import android.security.keystore.KeyProperties; -import androidx.annotation.RequiresApi; +import android.security.keystore.UserNotAuthenticatedException; import android.util.Base64; import android.util.JsonWriter; +import androidx.annotation.RequiresApi; + import com.termux.api.TermuxApiReceiver; import com.termux.api.util.ResultReturner; import com.termux.api.util.ResultReturner.ResultJsonWriter; import com.termux.api.util.ResultReturner.WithInput; import com.termux.shared.logger.Logger; +import com.termux.shared.settings.preferences.SharedPreferenceUtils; +import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Paths; import java.security.GeneralSecurityException; +import java.security.Key; import java.security.KeyFactory; import java.security.KeyPairGenerator; import java.security.KeyStore; import java.security.KeyStore.Entry; import java.security.KeyStore.PrivateKeyEntry; +import java.security.KeyStore.SecretKeyEntry; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; -import java.security.PublicKey; import java.security.Signature; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPublicKey; -import java.security.spec.ECGenParameterSpec; -import java.security.spec.RSAKeyGenParameterSpec; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; + +import java.security.spec.InvalidKeySpecException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Enumeration; +import java.util.Iterator; +import java.util.stream.Collectors; public class KeystoreAPI { @@ -43,20 +69,22 @@ public class KeystoreAPI { // this is the only provider name that is supported by Android private static final String PROVIDER = "AndroidKeyStore"; + private static final String PREFERENCES_PREFIX = "keystore_api__encrypted_data"; + private static final int MAX_AUTH_RETRIES = 1; @SuppressLint("NewApi") - public static void onReceive(TermuxApiReceiver apiReceiver, Intent intent) { + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { Logger.logDebug(LOG_TAG, "onReceive"); switch (intent.getStringExtra("command")) { case "list": - listKeys(apiReceiver, intent); + listData(apiReceiver, context, intent); break; case "generate": generateKey(apiReceiver, intent); break; case "delete": - deleteKey(apiReceiver, intent); + deleteData(apiReceiver, context, intent); break; case "sign": signData(apiReceiver, intent); @@ -64,24 +92,37 @@ public static void onReceive(TermuxApiReceiver apiReceiver, Intent intent) { case "verify": verifyData(apiReceiver, intent); break; + case "encrypt": + encryptData(apiReceiver, context, intent); + break; + case "decrypt": + decryptData(apiReceiver, context, intent); + break; } } /** - * List the keys inside the keystore.
+ * List either the keys inside the keystore or data in shared preferences.
* Optional intent extras: * */ @RequiresApi(api = Build.VERSION_CODES.M) - private static void listKeys(TermuxApiReceiver apiReceiver, final Intent intent) { + private static void listData(TermuxApiReceiver apiReceiver, final Context context, + final Intent intent) { ResultReturner.returnData(apiReceiver, intent, new ResultJsonWriter() { @Override - public void writeJson(JsonWriter out) throws GeneralSecurityException, IOException { + public void writeJson(JsonWriter out) + throws GeneralSecurityException, IOException, JSONException { KeyStore keyStore = getKeyStore(); Enumeration aliases = keyStore.aliases(); - boolean detailed = intent.getBooleanExtra("detailed", false); + boolean detailed = (intent.getIntExtra("detailed", 0) == 1); + boolean pref = (intent.getIntExtra("pref", 0) == 1); out.beginArray(); while (aliases.hasMoreElements()) { @@ -90,9 +131,21 @@ public void writeJson(JsonWriter out) throws GeneralSecurityException, IOExcepti String alias = aliases.nextElement(); out.name("alias").value(alias); - Entry entry = keyStore.getEntry(alias, null); - if (entry instanceof PrivateKeyEntry) { - printPrivateKey(out, (PrivateKeyEntry) entry, detailed); + if (pref) { + JSONObject prefsJSON = getPrefsJSON(context, alias); + Iterator prefKeys = prefsJSON.keys(); + out.name("Preferences"); + out.beginObject(); + while (prefKeys.hasNext()) { + String transientKey = prefKeys.next(); + out.name(transientKey) + .value(detailed ? prefsJSON.getString(transientKey) : ""); + } + out.endObject(); + } else { + Key key = getKey(alias, false); + printKey(out, detailed, key, + key instanceof PrivateKey ? getKey(alias, true) : null); } out.endObject(); @@ -106,60 +159,108 @@ public void writeJson(JsonWriter out) throws GeneralSecurityException, IOExcepti * Helper function for printing the parameters of a given key. */ @RequiresApi(api = Build.VERSION_CODES.M) - private static void printPrivateKey(JsonWriter out, PrivateKeyEntry entry, boolean detailed) - throws GeneralSecurityException, IOException { - PrivateKey privateKey = entry.getPrivateKey(); - String algorithm = privateKey.getAlgorithm(); - KeyInfo keyInfo = KeyFactory.getInstance(algorithm).getKeySpec(privateKey, KeyInfo.class); - - PublicKey publicKey = entry.getCertificate().getPublicKey(); + private static void printKey(JsonWriter out, boolean detailed, Key key, Key pubKey) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String algorithm = key.getAlgorithm(); + KeyInfo keyInfo = getKeyInfo(key); + + String mode = String.join(",", keyInfo.getBlockModes()); + String padding = String.join(",", keyInfo.getEncryptionPaddings()); + boolean authRequired = keyInfo.isUserAuthenticationRequired(); + int validityDuration = keyInfo.getUserAuthenticationValidityDurationSeconds(); - out.name("algorithm").value(algorithm); + out.name("algorithm"); + out.beginObject(); + out.name("name").value(algorithm); + if (!mode.isEmpty()) { + out.name("block_mode").value(mode); + out.name("encryption_padding").value(padding); + } + out.endObject(); out.name("size").value(keyInfo.getKeySize()); + out.name("purposes").value(decomposeBinary(keyInfo.getPurposes()) + .stream().map(String::valueOf) + .collect(Collectors.joining("|"))); - if (detailed && publicKey instanceof RSAPublicKey) { - RSAPublicKey rsa = (RSAPublicKey) publicKey; + out.name("inside_secure_hardware").value(keyInfo.isInsideSecureHardware()); + + out.name("user_authentication"); + out.beginObject(); + out.name("required").value(authRequired); + out.name("enforced_by_secure_hardware") + .value(keyInfo.isUserAuthenticationRequirementEnforcedBySecureHardware()); + if (validityDuration >= 0) out.name("validity_duration_seconds") + .value(validityDuration); + if (detailed && authRequired) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { //Can't test yet + out.name("authentication_type") + .value(decomposeBinary(keyInfo.getUserAuthenticationType()) + .stream().map(String::valueOf) + .collect(Collectors.joining("|"))); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + out.name("invalidated_by_new_biometric") + .value(keyInfo.isInvalidatedByBiometricEnrollment()); + } + } + out.endObject(); + + if (detailed && pubKey instanceof RSAPublicKey) { + RSAPublicKey rsa = (RSAPublicKey) pubKey; // convert to hex out.name("modulus").value(rsa.getModulus().toString(16)); out.name("exponent").value(rsa.getPublicExponent().toString(16)); } - if (detailed && publicKey instanceof ECPublicKey) { - ECPublicKey ec = (ECPublicKey) publicKey; + if (detailed && pubKey instanceof ECPublicKey) { + ECPublicKey ec = (ECPublicKey) pubKey; // convert to hex out.name("x").value(ec.getW().getAffineX().toString(16)); out.name("y").value(ec.getW().getAffineY().toString(16)); } + } - out.name("inside_secure_hardware").value(keyInfo.isInsideSecureHardware()); - - out.name("user_authentication"); - - out.beginObject(); - out.name("required").value(keyInfo.isUserAuthenticationRequired()); - - out.name("enforced_by_secure_hardware"); - out.value(keyInfo.isUserAuthenticationRequirementEnforcedBySecureHardware()); - - int validityDuration = keyInfo.getUserAuthenticationValidityDurationSeconds(); - if (validityDuration >= 0) { - out.name("validity_duration_seconds").value(validityDuration); + /** + * Decomposes binary for options (e.g. 3->{1,2}). + */ + private static ArrayList decomposeBinary(int binary) { + ArrayList values = new ArrayList<>(); + int power = 0; + while (binary != 0) { + if ((binary & 1) != 0) values.add(1<>= 1; } - out.endObject(); + return values; } /** - * Permanently delete a key from the keystore.
+ * Permanently delete a key from the keystore or a specified shared preference.
* Required intent extras: *
    - *
  • alias: key alias
  • + *
  • alias: key alias.
  • + *
  • pref: deletes specified preference of alias instead of key.
  • *
*/ - private static void deleteKey(TermuxApiReceiver apiReceiver, final Intent intent) { + private static void deleteData(TermuxApiReceiver apiReceiver, final Context context, + final Intent intent) { ResultReturner.returnData(apiReceiver, intent, out -> { String alias = intent.getStringExtra("alias"); - // unfortunately this statement does not return anything - // nor does it throw an exception if the alias does not exist - getKeyStore().deleteEntry(alias); + String pref = intent.getStringExtra("pref"); + + if (!"-1".equals(pref)) { + JSONObject prefsJSON = getPrefsJSON(context, alias); + prefsJSON.remove(pref); + setPrefsJSON(context, alias, prefsJSON); + } else { + // unfortunately this statement does not return anything + // nor does it throw an exception if the alias does not exist + getKeyStore().deleteEntry(alias); + TermuxAPIAppSharedPreferences.build(context) + .getSharedPreferences() + .edit() + .remove(String.join("__", PREFERENCES_PREFIX, alias)) + .commit(); + } }); } @@ -167,28 +268,53 @@ private static void deleteKey(TermuxApiReceiver apiReceiver, final Intent intent * Create a new key inside the keystore.
* Required intent extras: *
    - *
  • alias: key alias
  • + *
  • alias: key alias.
  • *
  • * algorithm: key algorithm, should be one of the KeyProperties.KEY_ALGORITHM_* * values, for example {@link KeyProperties#KEY_ALGORITHM_RSA} or - * {@link KeyProperties#KEY_ALGORITHM_EC}. + * {@link KeyProperties#KEY_ALGORITHM_AES}. + *
  • + *
  • + * mode: encryption block mode, should be one of the KeyProperties.BLOCK_MODE_* + * values, for example {@link KeyProperties#BLOCK_MODE_GCM} or + * {@link KeyProperties#BLOCK_MODE_CBC}. + *
  • + *
  • + * padding: encryption padding, should be one of the KeyProperties.ENCRYPTION_PADDING_* + * values, for example {@link KeyProperties#ENCRYPTION_PADDING_NONE} or + * {@link KeyProperties#ENCRYPTION_PADDING_PKCS7}. (the full list of supported Cipher + * combinations can be found at + * + * the Android documentation). *
  • + *
  • size: key size.
  • *
  • * purposes: purposes of this key, should be a combination of * KeyProperties.PURPOSE_*, for example 12 for * {@link KeyProperties#PURPOSE_SIGN}+{@link KeyProperties#PURPOSE_VERIFY} + * or 3 for + * {@link KeyProperties#PURPOSE_ENCRYPT}+{@link KeyProperties#PURPOSE_DECRYPT}. *
  • *
  • * digests: set of hashes this key can be used with, should be an array of * KeyProperties.DIGEST_* values, for example - * {@link KeyProperties#DIGEST_SHA256} and {@link KeyProperties#DIGEST_SHA512} + * {@link KeyProperties#DIGEST_SHA256} and {@link KeyProperties#DIGEST_SHA512}. + *
  • + *
  • + * unlocked: set whether key is only valid if device is unlocked. + *
  • + *
  • + * validity: number of seconds where it is allowed to use this key for signing + * after unlocking the device (re-locking and unlocking restarts the timer), if set to + * -1 then key requires authentication for every use. *
  • - *
  • size: key size, only used for RSA keys
  • - *
  • curve: elliptic curve name, only used for EC keys
  • *
  • - * userValidity: number of seconds where it is allowed to use this key for signing - * after unlocking the device (re-locking and unlocking restarts the timer), if set to 0 - * this feature is disabled (i.e. the key can be used anytime) + * invalidate: set whether new biometric enrollments invalidate the key. + *
  • + *
  • + * auth: key authorizations which can enable key access, for example + * {@link KeyProperties#AUTH_DEVICE_CREDENTIAL} and + * {@link KeyProperties#AUTH_BIOMETRIC_STRONG}. *
  • *
*/ @@ -198,35 +324,51 @@ private static void generateKey(TermuxApiReceiver apiReceiver, final Intent inte ResultReturner.returnData(apiReceiver, intent, out -> { String alias = intent.getStringExtra("alias"); String algorithm = intent.getStringExtra("algorithm"); + String mode = intent.getStringExtra("mode"); + String padding = intent.getStringExtra("padding"); + int size = intent.getIntExtra("size", 2048); int purposes = intent.getIntExtra("purposes", 0); String[] digests = intent.getStringArrayExtra("digests"); - int size = intent.getIntExtra("size", 2048); - String curve = intent.getStringExtra("curve"); - int userValidity = intent.getIntExtra("validity", 0); + boolean unlocked = (intent.getIntExtra("unlocked", 1) == 1); + int userValidity = intent.getIntExtra("validity", -1); + boolean invalidate = (intent.getIntExtra("invalidate", 0) == 1); + int authorizations = intent.getIntExtra("auth", 0); KeyGenParameterSpec.Builder builder = - new KeyGenParameterSpec.Builder(alias, purposes); - - builder.setDigests(digests); - if (algorithm.equals(KeyProperties.KEY_ALGORITHM_RSA)) { - // only the exponent 65537 is supported for now - builder.setAlgorithmParameterSpec( - new RSAKeyGenParameterSpec(size, RSAKeyGenParameterSpec.F4)); - builder.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1); + new KeyGenParameterSpec.Builder(alias, purposes) + .setKeySize(size) + .setUserAuthenticationRequired((authorizations != 0)) + .setUserAuthenticationValidityDurationSeconds(userValidity); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setInvalidatedByBiometricEnrollment(invalidate); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setUnlockedDeviceRequired(unlocked); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters(userValidity, authorizations); //Can't test yet + } + } } + if (!"-1".equals(mode)) builder.setBlockModes(mode).setEncryptionPaddings(padding); - if (algorithm.equals(KeyProperties.KEY_ALGORITHM_EC)) { - builder.setAlgorithmParameterSpec(new ECGenParameterSpec(curve)); + if (KeyProperties.BLOCK_MODE_ECB.equals(mode) && + (KeyProperties.ENCRYPTION_PADDING_NONE.equals(padding) || + KeyProperties.ENCRYPTION_PADDING_PKCS7.equals(padding))) { + builder.setRandomizedEncryptionRequired(false); } - if (userValidity > 0) { - builder.setUserAuthenticationRequired(true); - builder.setUserAuthenticationValidityDurationSeconds(userValidity); + if (KeyProperties.KEY_ALGORITHM_AES.equals(algorithm)) { + KeyGenerator generator = KeyGenerator.getInstance(algorithm, PROVIDER); + generator.init(builder.build()); + generator.generateKey(); + } else { + builder.setDigests(digests); + if (KeyProperties.KEY_ALGORITHM_RSA.equals(algorithm)) { + builder.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1); + } + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm, PROVIDER); + generator.initialize(builder.build()); + generator.generateKeyPair(); } - - KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm, PROVIDER); - generator.initialize(builder.build()); - generator.generateKeyPair(); }); } @@ -235,12 +377,12 @@ private static void generateKey(TermuxApiReceiver apiReceiver, final Intent inte * The output is encoded using base64.
* Required intent extras: *
    - *
  • alias: key alias
  • + *
  • alias: key alias.
  • *
  • * algorithm: key algorithm and hash combination to use, e.g. SHA512withRSA * (the full list can be found at * - * the Android documentation) + * the Android documentation). *
  • *
*/ @@ -270,14 +412,14 @@ public void writeResult(PrintWriter out) throws Exception { * The file is read from stdin, and a "true" or "false" message is printed to the stdout.
* Required intent extras: *
    - *
  • alias: key alias
  • + *
  • alias: key alias.
  • *
  • * algorithm: key algorithm and hash combination that was used to create this signature, * e.g. SHA512withRSA (the full list can be found at * - * the Android documentation) + * the Android documentation). *
  • - *
  • signature: path of the signature file
  • + *
  • signature: path of the signature file.
  • *
*/ private static void verifyData(TermuxApiReceiver apiReceiver, final Intent intent) { @@ -303,6 +445,232 @@ public void writeResult(PrintWriter out) throws GeneralSecurityException, IOExce }); } + /** + * Encrypt a given byte stream. + * The data is read from a file or stdin (in that precedence), and the encrypted data is + * encoded using base64 then output to stdout and/or shared preferences with a given name. + * Output is of the form [IV.length][IV][Encrypted Data] (IV omitted if IV.length is 0).
+ * Required intent extras: + *
    + *
  • alias: key alias.
  • + *
  • + * algorithm: key algorithm, should be of the form 'ALG/MODE/PADDING' + * (the full list of supported Cipher combinations can be found at + * + * the Android documentation). + *
  • + *
  • path: the input file containing data to be encrypted.
  • + *
  • store: the name of the shared preference to store the encrypted data.
  • + *
  • quiet: if set, will not output to stdout.
  • + *
+ */ + private static void encryptData(TermuxApiReceiver apiReceiver, final Context context, + final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new WithInput() { + @Override + public void writeResult(PrintWriter out) + throws GeneralSecurityException, IOException, JSONException { + String alias = intent.getStringExtra("alias"); + String algorithm = intent.getStringExtra("algorithm"); + String path = intent.getStringExtra("filepath"); + String store = intent.getStringExtra("store"); + boolean quiet = (intent.getIntExtra("quiet", 0) == 1); + + ByteArrayOutputStream encrypted = new ByteArrayOutputStream(); + byte[] input = "-1".equals(path) ? readStream(in) : readFile(path); + + Cipher cipher = cipherCall(context, intent, Cipher.ENCRYPT_MODE, + alias, algorithm, null); + + byte[] encryptedData = cipher.doFinal(input); Arrays.fill(input, (byte) 0); + byte[] iv = cipher.getIV(); + if (iv == null) { + encrypted.write((byte) 0); + } else { + encrypted.write(iv.length); + encrypted.write(iv); + Arrays.fill(iv, (byte) 0); + } + encrypted.write(encryptedData); Arrays.fill(encryptedData, (byte) 0); + + // we are not allowed to output bytes in this function + // one option is to encode using base64 which is a plain string + if (!quiet) out.write(Base64.encodeToString( + encrypted.toByteArray(), Base64.NO_WRAP)); + if (!"-1".equals(store)) { + JSONObject prefsJSON = getPrefsJSON(context, alias); + prefsJSON.put(store, Base64.encodeToString( + encrypted.toByteArray(), Base64.NO_WRAP)); + setPrefsJSON(context, alias, prefsJSON); + } + encrypted.reset(); + } + }); + } + + /** + * Decrypt a given byte stream. + * The data is read from a file, shared preferences, or stdin (in that precedence), and the + * decrypted data can be output to stdout. Input is expected in the form + * [IV.length][IV][Encrypted Data] (IV omitted if IV.length is 0).
+ * Required intent extras: + *
    + *
  • alias: key alias.
  • + *
  • + * algorithm: key algorithm, should be of the form 'ALG/MODE/PADDING' + * (the full list of supported Cipher combinations can be found at + * + * the Android documentation). + *
  • + *
  • path: the input file containing data to be decrypted.
  • + *
  • store: the name of the shared preference containing data to be decrypted.
  • + *
  • quiet: if set, will not output to stdout.
  • + *
+ */ + private static void decryptData(TermuxApiReceiver apiReceiver, final Context context, + final Intent intent) { + ResultReturner.returnData(apiReceiver, intent, new WithInput() { + @Override + public void writeResult(PrintWriter out) + throws GeneralSecurityException, IOException, JSONException { + String alias = intent.getStringExtra("alias"); + String algorithm = intent.getStringExtra("algorithm"); + String path = intent.getStringExtra("filepath"); + String store = intent.getStringExtra("store"); + boolean quiet = (intent.getIntExtra("quiet", 0) == 1); + + ByteArrayOutputStream decrypted = new ByteArrayOutputStream(); + byte[] input; + if ("-1".equals(path)) { + if (!"-1".equals(store)) { + JSONObject prefsJSON = getPrefsJSON(context, alias); + input = Base64.decode(prefsJSON.getString(store), Base64.NO_WRAP); + } else { + input = readStream(in); + } + } else { + input = readFile(path); + } + + Cipher cipher = cipherCall(context, intent, Cipher.DECRYPT_MODE, alias, algorithm, + input[0] == 0 ? null : input); + + byte[] decryptedData = cipher.doFinal(input, + input[0]+1, input.length-input[0]-1); + Arrays.fill(input, (byte) 0); + decrypted.write(decryptedData); Arrays.fill(decryptedData, (byte) 0); + + // we are not allowed to output bytes in this function + // one option is to encode using base64 which is a plain string + if (!quiet) out.write(Base64.encodeToString( + decrypted.toByteArray(), Base64.NO_WRAP)); + decrypted.reset(); + } + }); + } + + /** + * Tries to initialize cipher and prompts for authentication if timed-out. + */ + private static Cipher cipherCall(Context context, Intent intent, int mode, String alias, + String algorithm, byte[] input) + throws GeneralSecurityException, IOException { + int count = 0; + boolean[] auths = {true, true}; + Key key = getKey(alias, (mode == Cipher.ENCRYPT_MODE)); + KeyInfo keyInfo = getKeyInfo(key); + if ("-1".equals(algorithm)) { + algorithm = String.join("/", + key.getAlgorithm(), + keyInfo.getBlockModes()[0], + keyInfo.getEncryptionPaddings()[0]); + Logger.logDebug(LOG_TAG, "Cipher algorithm not specified, using: " + algorithm); + } + Cipher cipher = Cipher.getInstance(algorithm); + + do { + try { + if (input == null) cipher.init(mode, key); + else { + cipher.init(mode, key, getIVSpec(input, algorithm.split("/", 3)[1])); + } + return cipher; + } catch (UserNotAuthenticatedException e) { + if (count == 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ArrayList authList = decomposeBinary(keyInfo + .getUserAuthenticationType()); + auths[0] = authList.contains(KeyProperties.AUTH_DEVICE_CREDENTIAL); + auths[1] = authList.contains(KeyProperties.AUTH_BIOMETRIC_STRONG); + } + intent.putExtra("auths", auths); + intent.putExtra("title", " "); + intent.putExtra("subtitle", "Authentication required for key"); + intent.putExtra("description", ""); + intent.putExtra(FingerprintAPI.EXTRA_LOCK_ACTION, true); + } + if (count <= MAX_AUTH_RETRIES) { + FingerprintAPI.onReceive(context, intent); + } else { + Logger.logError(LOG_TAG, String.valueOf(e)); + throw e; + } + } + } while (count++ <= MAX_AUTH_RETRIES); + + return null; + } + + /** + * Get IV Parameter Spec. + */ + private static AlgorithmParameterSpec getIVSpec(byte[] input, String mode) { + switch(mode) { + case "CBC": + case "CTR": { + return new IvParameterSpec(input, 1, input[0]); + } + case "GCM": { + return new GCMParameterSpec(128, input, 1, input[0]); + } + default: { + String e = "Invalid Cipher Block. See: Android keystore#SupportedCiphers"; + Logger.logError(LOG_TAG, e); + throw new IllegalArgumentException(e); + } + } + } + + /** + * Get Key. + */ + private static Key getKey(String alias, boolean encrypt) + throws GeneralSecurityException, IOException { + Entry entry = getKeyStore().getEntry(alias, null); + if (entry instanceof PrivateKeyEntry) { + return encrypt ? ((PrivateKeyEntry) entry).getCertificate().getPublicKey() + : ((PrivateKeyEntry) entry).getPrivateKey(); + } else if (entry instanceof SecretKeyEntry) { + return ((SecretKeyEntry) entry).getSecretKey(); + } else { + String e = "Invalid Cipher Algorithm. See: Android keystore#SupportedCiphers"; + Logger.logError(LOG_TAG, e); + throw new IllegalArgumentException(e); + } + } + + /** + * Get KeyInfo + */ + private static KeyInfo getKeyInfo(Key key) + throws NoSuchAlgorithmException, InvalidKeySpecException { + String algorithm = key.getAlgorithm(); + return (key instanceof PrivateKey) ? + KeyFactory.getInstance(algorithm).getKeySpec(key, KeyInfo.class) + : (KeyInfo) SecretKeyFactory.getInstance(algorithm) + .getKeySpec((SecretKey) key, KeyInfo.class); + } + /** * Set up and return the keystore. */ @@ -312,6 +680,43 @@ private static KeyStore getKeyStore() throws GeneralSecurityException, IOExcepti return keyStore; } + /** + * Set Shared Preferences in JSON for given key alias. + */ + private static void setPrefsJSON(Context context, String alias, JSONObject value) { + SharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context) + .getSharedPreferences(); + SharedPreferenceUtils.setString(preferences, + String.join("__", PREFERENCES_PREFIX, alias), + value.toString(), true); + } + + /** + * Get Shared Preferences in JSON for given key alias. + */ + private static JSONObject getPrefsJSON(Context context, String alias) throws JSONException { + SharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context) + .getSharedPreferences(); + return new JSONObject(SharedPreferenceUtils.getString(preferences, + String.join("__", PREFERENCES_PREFIX, alias), + "{}", true)); + } + + /** + * Read file to byte array. + */ + private static byte[] readFile(String path) throws IOException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return Files.readAllBytes(Paths.get(path)); + } else { + File file = new File(path); + byte[] data = new byte[(int) file.length()]; + BufferedInputStream buffer = new BufferedInputStream(new FileInputStream(file)); + buffer.read(data, 0, data.length); + buffer.close(); + return data; + } + } /** * Read a given stream to a byte array. Should not be used with large streams. @@ -329,4 +734,4 @@ private static byte[] readStream(InputStream stream) throws IOException { private static void printErrorMessage(TermuxApiReceiver apiReceiver, Intent intent) { ResultReturner.returnData(apiReceiver, intent, out -> out.println("termux-keystore requires at least Android 6.0 (Marshmallow).")); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/termux/api/util/PluginUtils.java b/app/src/main/java/com/termux/api/util/PluginUtils.java new file mode 100644 index 000000000..0fc85e113 --- /dev/null +++ b/app/src/main/java/com/termux/api/util/PluginUtils.java @@ -0,0 +1,38 @@ +package com.termux.api.util; + +import android.content.Context; + +import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; +import com.termux.shared.termux.settings.preferences.TermuxPreferenceConstants.TERMUX_API_APP; + +/** + * Based on com.termux.tasker.utils.PluginUtils, reference for more information. + */ +public class PluginUtils { + + /** + * Try to get the next unique {PendingIntent} request code that isn't already being used by + * the app and which would create a unique {PendingIntent} that doesn't conflict with that + * of any other execution commands. + * + * @param context The {@link Context} for operations. + * @return Returns the request code that should be safe to use. + */ + public synchronized static int getLastPendingIntentRequestCode(final Context context) { + if (context == null) return TERMUX_API_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE; + + TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context); + if (preferences == null) return TERMUX_API_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE; + + int lastPendingIntentRequestCode = preferences.getLastPendingIntentRequestCode(); + + int nextPendingIntentRequestCode = lastPendingIntentRequestCode + 1; + + if (nextPendingIntentRequestCode == Integer.MAX_VALUE || nextPendingIntentRequestCode < 0) + nextPendingIntentRequestCode = TERMUX_API_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE; + + preferences.setLastPendingIntentRequestCode(nextPendingIntentRequestCode); + return nextPendingIntentRequestCode; + } + +} \ No newline at end of file