|
| 1 | +package im.status.tablet; |
| 2 | + |
| 3 | +import android.app.Activity; |
| 4 | +import android.content.Context; |
| 5 | +import android.content.DialogInterface; |
| 6 | +import android.hardware.biometrics.BiometricManager; |
| 7 | +import android.hardware.biometrics.BiometricPrompt; |
| 8 | +import android.hardware.fingerprint.FingerprintManager; |
| 9 | +import android.os.Build; |
| 10 | +import android.os.CancellationSignal; |
| 11 | +import android.util.Base64; |
| 12 | +import android.util.Log; |
| 13 | + |
| 14 | +import java.nio.charset.StandardCharsets; |
| 15 | +import java.security.KeyStore; |
| 16 | +import java.util.concurrent.Executor; |
| 17 | +import java.util.concurrent.Executors; |
| 18 | + |
| 19 | +import javax.crypto.Cipher; |
| 20 | +import javax.crypto.KeyGenerator; |
| 21 | +import javax.crypto.SecretKey; |
| 22 | +import javax.crypto.spec.GCMParameterSpec; |
| 23 | + |
| 24 | +import android.security.keystore.KeyGenParameterSpec; |
| 25 | +import android.security.keystore.KeyProperties; |
| 26 | + |
| 27 | +/** |
| 28 | + * Framework-only (no AndroidX) biometric helper used from Qt via JNI. |
| 29 | + * - AES/GCM key in AndroidKeyStore (user-auth required). |
| 30 | + * - Stores {iv,ciphertext} per account in SharedPreferences. |
| 31 | + * - Exposes beginSaveCredential / beginGetCredential flows guarded by BiometricPrompt. |
| 32 | + * |
| 33 | + * Public API and native callback signatures are kept intact. |
| 34 | + */ |
| 35 | +public final class SecureAndroidAuthentication { |
| 36 | + |
| 37 | + // ====== Logging ====== |
| 38 | + private static final String TAG = "SecureAndroidAuthentication"; |
| 39 | + |
| 40 | + // ====== Singleton ====== |
| 41 | + private static SecureAndroidAuthentication sInst; |
| 42 | + |
| 43 | + public static synchronized SecureAndroidAuthentication getInstance(Context ctx) { |
| 44 | + if (sInst == null) sInst = new SecureAndroidAuthentication(ctx.getApplicationContext()); |
| 45 | + return sInst; |
| 46 | + } |
| 47 | + |
| 48 | + // ====== Ctor / fields ====== |
| 49 | + private final Context mContext; // application context (safe to keep) |
| 50 | + @SuppressWarnings("FieldCanBeLocal") |
| 51 | + private final Activity mActivityInstance = null; // kept for source compatibility (unused) |
| 52 | + private final Executor mExecutor = Executors.newSingleThreadExecutor(); |
| 53 | + |
| 54 | + // Prompt configuration (non-empty defaults to avoid framework exceptions on API 28) |
| 55 | + private int mAppAuthMask = 0; // 1=STRONG, 2=WEAK, 4=DEVICE_CREDENTIAL (from C++) |
| 56 | + private String mTitle = "Authenticate"; |
| 57 | + private String mDescription = ""; |
| 58 | + private String mNegative = "Cancel"; |
| 59 | + |
| 60 | + // In-flight prompt |
| 61 | + private CancellationSignal mCancel; |
| 62 | + |
| 63 | + // Pending operation bookkeeping |
| 64 | + private enum PendingType { NONE, SAVE, GET } |
| 65 | + private PendingType pending = PendingType.NONE; |
| 66 | + private String pendingAccount; |
| 67 | + private String pendingPlain; // for SAVE |
| 68 | + private byte[] pendingIV; // for SAVE/GET |
| 69 | + |
| 70 | + private SecureAndroidAuthentication(Context ctx) { |
| 71 | + this.mContext = ctx; |
| 72 | + } |
| 73 | + |
| 74 | + // ====== Public API called from Qt (unchanged signatures) ====== |
| 75 | + |
| 76 | + public void setAuthenticators(int mask) { |
| 77 | + mAppAuthMask = mask; |
| 78 | + } |
| 79 | + public void setTitle(String title) { |
| 80 | + mTitle = (title != null && !title.trim().isEmpty()) ? title : "Authenticate"; |
| 81 | + } |
| 82 | + public void setDescription(String description) { |
| 83 | + mDescription = (description != null) ? description : ""; |
| 84 | + } |
| 85 | + public void setNegativeButton(String negativeButton) { |
| 86 | + mNegative = (negativeButton != null && !negativeButton.trim().isEmpty()) ? negativeButton : "Cancel"; |
| 87 | + } |
| 88 | + |
| 89 | + /** Cancel current biometric request, if any. */ |
| 90 | + public void cancel() { |
| 91 | + if (mCancel != null && !mCancel.isCanceled()) mCancel.cancel(); |
| 92 | + mCancel = null; |
| 93 | + } |
| 94 | + |
| 95 | + /** Capability check — returns your BIOMETRIC_* codes. */ |
| 96 | + public int canAuthenticate() { |
| 97 | + try { |
| 98 | + if (Build.VERSION.SDK_INT >= 30) { |
| 99 | + BiometricManager bm = mContext.getSystemService(BiometricManager.class); |
| 100 | + if (bm == null) return BIOMETRIC_ERROR_NO_HARDWARE; |
| 101 | + final int allowed = toFrameworkAllowedMask(); |
| 102 | + switch (bm.canAuthenticate(allowed)) { |
| 103 | + case BiometricManager.BIOMETRIC_SUCCESS: return BIOMETRIC_SUCCESS; |
| 104 | + case BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE: return BIOMETRIC_ERROR_NO_HARDWARE; |
| 105 | + case BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE: return BIOMETRIC_ERROR_HW_UNAVAILABLE; |
| 106 | + case BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED: return BIOMETRIC_ERROR_NONE_ENROLLED; |
| 107 | + case BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED: return BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED; |
| 108 | + default: return BIOMETRIC_STATUS_UNKNOWN; |
| 109 | + } |
| 110 | + } else if (Build.VERSION.SDK_INT >= 29) { |
| 111 | + BiometricManager bm = mContext.getSystemService(BiometricManager.class); |
| 112 | + if (bm == null) return BIOMETRIC_ERROR_NO_HARDWARE; |
| 113 | + switch (bm.canAuthenticate()) { // flags overload not available on 29 |
| 114 | + case BiometricManager.BIOMETRIC_SUCCESS: return BIOMETRIC_SUCCESS; |
| 115 | + case BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE: return BIOMETRIC_ERROR_NO_HARDWARE; |
| 116 | + case BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE: return BIOMETRIC_ERROR_HW_UNAVAILABLE; |
| 117 | + case BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED: return BIOMETRIC_ERROR_NONE_ENROLLED; |
| 118 | + default: return BIOMETRIC_STATUS_UNKNOWN; |
| 119 | + } |
| 120 | + } else { |
| 121 | + // API 23–28: fingerprint-only exposure |
| 122 | + FingerprintManager fm = mContext.getSystemService(FingerprintManager.class); |
| 123 | + if (fm == null || !fm.isHardwareDetected()) return BIOMETRIC_ERROR_NO_HARDWARE; |
| 124 | + if (!fm.hasEnrolledFingerprints()) return BIOMETRIC_ERROR_NONE_ENROLLED; |
| 125 | + return BIOMETRIC_SUCCESS; |
| 126 | + } |
| 127 | + } catch (Throwable t) { |
| 128 | + Log.w(TAG, "canAuthenticate error", t); |
| 129 | + return BIOMETRIC_STATUS_UNKNOWN; |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + /** Start SAVE flow (encrypt & persist). */ |
| 134 | + public boolean beginSaveCredential(String account, String password) { |
| 135 | + if (Build.VERSION.SDK_INT < 28) { nativeCredentialError(-10, "BiometricPrompt requires API 28"); return false; } |
| 136 | + try { |
| 137 | + Cipher enc = newEncryptCipher(); |
| 138 | + pending = PendingType.SAVE; |
| 139 | + pendingAccount = account; |
| 140 | + pendingPlain = password; |
| 141 | + pendingIV = enc.getIV(); |
| 142 | + |
| 143 | + mCancel = new CancellationSignal(); |
| 144 | + BiometricPrompt prompt = buildPrompt((d, which) -> { |
| 145 | + if (mCancel != null) mCancel.cancel(); |
| 146 | + nativeCredentialError(-11, "User cancelled"); |
| 147 | + }); |
| 148 | + prompt.authenticate(new BiometricPrompt.CryptoObject(enc), mCancel, mExecutor, new Callback()); |
| 149 | + return true; |
| 150 | + } catch (Exception e) { |
| 151 | + nativeCredentialError(-1, "beginSave: " + e.getMessage()); |
| 152 | + return false; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + /** Start GET flow (decrypt & return). */ |
| 157 | + public boolean beginGetCredential(String account) { |
| 158 | + if (Build.VERSION.SDK_INT < 28) { nativeCredentialError(-10, "BiometricPrompt requires API 28"); return false; } |
| 159 | + try { |
| 160 | + byte[] iv = loadIV(account); |
| 161 | + byte[] ct = loadCT(account); |
| 162 | + if (iv == null || ct == null) { nativeCredentialLoaded(account, null); return true; } |
| 163 | + |
| 164 | + Cipher dec = newDecryptCipher(iv); |
| 165 | + pending = PendingType.GET; |
| 166 | + pendingAccount = account; |
| 167 | + pendingPlain = null; |
| 168 | + pendingIV = iv; |
| 169 | + |
| 170 | + mCancel = new CancellationSignal(); |
| 171 | + BiometricPrompt prompt = buildPrompt((d, which) -> { |
| 172 | + if (mCancel != null) mCancel.cancel(); |
| 173 | + nativeCredentialError(-11, "User cancelled"); |
| 174 | + }); |
| 175 | + prompt.authenticate(new BiometricPrompt.CryptoObject(dec), mCancel, mExecutor, new Callback()); |
| 176 | + return true; |
| 177 | + } catch (Exception e) { |
| 178 | + nativeCredentialError(-2, "beginGet: " + e.getMessage()); |
| 179 | + return false; |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + /** Remove stored blob for an account. */ |
| 184 | + public boolean deleteCredential(String account) { |
| 185 | + return prefs().edit() |
| 186 | + .remove(KEY_IV_PREFIX + account) |
| 187 | + .remove(KEY_CT_PREFIX + account) |
| 188 | + .commit(); |
| 189 | + } |
| 190 | + |
| 191 | + /** Check presence-at-rest (no prompt). */ |
| 192 | + public boolean hasCredential(String account) { |
| 193 | + return prefs().contains(KEY_IV_PREFIX + account) && prefs().contains(KEY_CT_PREFIX + account); |
| 194 | + } |
| 195 | + |
| 196 | + // ====== Prompt building ====== |
| 197 | + |
| 198 | + private BiometricPrompt buildPrompt(DialogInterface.OnClickListener onNeg) { |
| 199 | + // Framework Builder accepts any Context (no need for Activity) |
| 200 | + BiometricPrompt.Builder b = new BiometricPrompt.Builder(mContext) |
| 201 | + .setTitle((mTitle == null || mTitle.trim().isEmpty()) ? "Authenticate" : mTitle) |
| 202 | + .setDescription(mDescription == null ? "" : mDescription); |
| 203 | + |
| 204 | + // On API 29 you *could* use device credential instead of a negative; keeping negative for consistency |
| 205 | + b.setNegativeButton((mNegative == null || mNegative.trim().isEmpty()) ? "Cancel" : mNegative, |
| 206 | + mExecutor, onNeg); |
| 207 | + |
| 208 | + return b.build(); |
| 209 | + } |
| 210 | + |
| 211 | + // ====== Keystore helpers ====== |
| 212 | + |
| 213 | + private static final String KC_ALIAS = "QtAT_Keychain_AES"; |
| 214 | + private static final String PREFS_NAME = "QtAT_Keychain"; |
| 215 | + private static final String KEY_CT_PREFIX = "ct_"; // ciphertext |
| 216 | + private static final String KEY_IV_PREFIX = "iv_"; // iv |
| 217 | + |
| 218 | + private SecretKey ensureKey() throws Exception { |
| 219 | + KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); |
| 220 | + ks.load(null); |
| 221 | + if (!ks.containsAlias(KC_ALIAS)) { |
| 222 | + KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder( |
| 223 | + KC_ALIAS, |
| 224 | + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) |
| 225 | + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) |
| 226 | + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) |
| 227 | + .setUserAuthenticationRequired(true) |
| 228 | + // Keep this behavior for compatibility (key invalidated on enroll changes) |
| 229 | + .setInvalidatedByBiometricEnrollment(true) |
| 230 | + .build(); |
| 231 | + KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); |
| 232 | + kg.init(spec); |
| 233 | + kg.generateKey(); |
| 234 | + } |
| 235 | + KeyStore.SecretKeyEntry e = (KeyStore.SecretKeyEntry) ks.getEntry(KC_ALIAS, null); |
| 236 | + return e.getSecretKey(); |
| 237 | + } |
| 238 | + |
| 239 | + private Cipher newEncryptCipher() throws Exception { |
| 240 | + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); |
| 241 | + c.init(Cipher.ENCRYPT_MODE, ensureKey()); |
| 242 | + return c; |
| 243 | + } |
| 244 | + |
| 245 | + private Cipher newDecryptCipher(byte[] iv) throws Exception { |
| 246 | + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); |
| 247 | + c.init(Cipher.DECRYPT_MODE, ensureKey(), new GCMParameterSpec(128, iv)); |
| 248 | + return c; |
| 249 | + } |
| 250 | + |
| 251 | + // ====== Storage helpers ====== |
| 252 | + |
| 253 | + private android.content.SharedPreferences prefs() { |
| 254 | + return mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); |
| 255 | + } |
| 256 | + |
| 257 | + private void storeBytes(String account, byte[] iv, byte[] ct) { |
| 258 | + prefs().edit() |
| 259 | + .putString(KEY_IV_PREFIX + account, Base64.encodeToString(iv, Base64.NO_WRAP)) |
| 260 | + .putString(KEY_CT_PREFIX + account, Base64.encodeToString(ct, Base64.NO_WRAP)) |
| 261 | + .apply(); |
| 262 | + } |
| 263 | + |
| 264 | + private byte[] loadIV(String account) { |
| 265 | + String b64 = prefs().getString(KEY_IV_PREFIX + account, null); |
| 266 | + return b64 == null ? null : Base64.decode(b64, Base64.NO_WRAP); |
| 267 | + } |
| 268 | + |
| 269 | + private byte[] loadCT(String account) { |
| 270 | + String b64 = prefs().getString(KEY_CT_PREFIX + account, null); |
| 271 | + return b64 == null ? null : Base64.decode(b64, Base64.NO_WRAP); |
| 272 | + } |
| 273 | + |
| 274 | + // ====== Biometric callback ====== |
| 275 | + |
| 276 | + private final class Callback extends BiometricPrompt.AuthenticationCallback { |
| 277 | + @Override public void onAuthenticationError(int code, CharSequence err) { |
| 278 | + try { |
| 279 | + nativeCredentialError(code, String.valueOf(err)); |
| 280 | + } finally { |
| 281 | + clearPending(); |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + @Override public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) { |
| 286 | + try { |
| 287 | + if (pending == PendingType.SAVE) { |
| 288 | + // Encrypt path already performed; just persist ct + iv |
| 289 | + Cipher enc = result.getCryptoObject().getCipher(); |
| 290 | + byte[] ct = enc.doFinal(pendingPlain.getBytes(StandardCharsets.UTF_8)); |
| 291 | + storeBytes(pendingAccount, pendingIV, ct); |
| 292 | + nativeCredentialSaved(true); |
| 293 | + } else if (pending == PendingType.GET) { |
| 294 | + byte[] ct = loadCT(pendingAccount); |
| 295 | + Cipher dec = result.getCryptoObject().getCipher(); |
| 296 | + String plain = new String(dec.doFinal(ct), StandardCharsets.UTF_8); |
| 297 | + nativeCredentialLoaded(pendingAccount, plain); |
| 298 | + } else { |
| 299 | + nativeCredentialError(-3, "No pending op"); |
| 300 | + } |
| 301 | + } catch (Exception e) { |
| 302 | + nativeCredentialError(-4, "onSucceeded: " + e.getMessage()); |
| 303 | + } finally { |
| 304 | + clearPending(); |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + @Override public void onAuthenticationFailed() { |
| 309 | + // Called when a biometric (e.g., fingerprint) is recognized but not matched |
| 310 | + // Do nothing special; the system keeps listening. We only report terminal results. |
| 311 | + } |
| 312 | + } |
| 313 | + |
| 314 | + private void clearPending() { |
| 315 | + pending = PendingType.NONE; |
| 316 | + pendingAccount = null; |
| 317 | + pendingPlain = null; |
| 318 | + pendingIV = null; |
| 319 | + mCancel = null; |
| 320 | + } |
| 321 | + |
| 322 | + // ====== App-mask -> framework-mask mapping (API 30+) ====== |
| 323 | + // C++ sends: 1=STRONG, 2=WEAK, 4=DEVICE_CREDENTIAL |
| 324 | + private int toFrameworkAllowedMask() { |
| 325 | + if (Build.VERSION.SDK_INT < 30) return 0; |
| 326 | + int fw = 0; |
| 327 | + if ((mAppAuthMask & 0x01) != 0) |
| 328 | + fw |= BiometricManager.Authenticators.BIOMETRIC_STRONG; |
| 329 | + if ((mAppAuthMask & 0x02) != 0) |
| 330 | + fw |= BiometricManager.Authenticators.BIOMETRIC_WEAK; |
| 331 | + if ((mAppAuthMask & 0x04) != 0) |
| 332 | + fw |= BiometricManager.Authenticators.DEVICE_CREDENTIAL; |
| 333 | + |
| 334 | + if (fw == 0) { |
| 335 | + fw = BiometricManager.Authenticators.BIOMETRIC_STRONG |
| 336 | + | BiometricManager.Authenticators.DEVICE_CREDENTIAL; |
| 337 | + } |
| 338 | + return fw; |
| 339 | + } |
| 340 | + |
| 341 | + // ====== Native callbacks (kept static) ====== |
| 342 | + private static native void nativeCredentialSaved(boolean ok); |
| 343 | + private static native void nativeCredentialLoaded(String account, String secret); |
| 344 | + private static native void nativeCredentialError(int code, String message); |
| 345 | + |
| 346 | + // ====== Result/status constants (kept as-is) ====== |
| 347 | + private final int BIOMETRIC_STRONG = 0x01; |
| 348 | + private final int BIOMETRIC_WEAK = 0x02; |
| 349 | + private final int DEVICE_CREDENTIAL = 0x04; |
| 350 | + |
| 351 | + private final int BIOMETRIC_STATUS_UNKNOWN = 0; |
| 352 | + private final int BIOMETRIC_SUCCESS = 1; |
| 353 | + private final int BIOMETRIC_ERROR_NO_HARDWARE = 2; |
| 354 | + private final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 3; |
| 355 | + private final int BIOMETRIC_ERROR_NONE_ENROLLED = 4; |
| 356 | + private final int BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED = 5; |
| 357 | +} |
0 commit comments