Skip to content

Commit e6b75f1

Browse files
feat(@mobile/biometrics): Add suport for biometrics on Android
Uses Biometric Manager with CryptoObject to make it secure and robust
1 parent 894f80d commit e6b75f1

File tree

5 files changed

+645
-1
lines changed

5 files changed

+645
-1
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
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+
}

mobile/wrapperApp/Status-tablet.pro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ android {
3434
$$PWD/../lib/$$LIB_PREFIX/libDOtherSide$$(LIB_SUFFIX)$$(LIB_EXT) \
3535
$$PWD/../lib/$$LIB_PREFIX/libstatus.so \
3636
$$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT)
37+
38+
OTHER_FILES += \
39+
android/src/im/status/tablet/SecureAndroidAuthentication.java
3740
}
3841

3942
ios {

ui/StatusQ/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ elseif (${CMAKE_SYSTEM_NAME} MATCHES "iOS")
230230
src/statuswindow_other.cpp
231231
src/keychain_other.cpp
232232
)
233+
elseif (${CMAKE_SYSTEM_NAME} MATCHES "Android")
234+
target_sources(StatusQ PRIVATE
235+
src/statuswindow_other.cpp
236+
src/keychain_android.cpp
237+
)
233238
else ()
234239
target_sources(StatusQ PRIVATE
235240
src/statuswindow_other.cpp

0 commit comments

Comments
 (0)