diff --git a/src-tauri/android/app/build.gradle.kts b/src-tauri/android/app/build.gradle.kts
index b6cc2de8..f8d46518 100644
--- a/src-tauri/android/app/build.gradle.kts
+++ b/src-tauri/android/app/build.gradle.kts
@@ -107,8 +107,17 @@ android {
)
}
}
+ // Java 17 bytecode. AGP 8.11 already requires a JDK 17 toolchain to run
+ // (the builder image ships openjdk-17), so "1.8" was only capping the
+ // bytecode we emit, not the JDK in use. Kotlin's jvmTarget and javac's
+ // source/targetCompatibility must agree or AGP 8 fails the build, so all
+ // three move together.
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "17"
}
buildFeatures {
buildConfig = true
diff --git a/src-tauri/android/src/main/AndroidManifest.xml b/src-tauri/android/src/main/AndroidManifest.xml
index 6eb892ac..bd473e21 100644
--- a/src-tauri/android/src/main/AndroidManifest.xml
+++ b/src-tauri/android/src/main/AndroidManifest.xml
@@ -27,16 +27,50 @@
-
-
+
+
+
+ android:usesCleartextTraffic="${usesCleartextTraffic}"
+ android:allowBackup="false"
+ android:dataExtractionRules="@xml/data_extraction_rules">
-
-
+
diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/security/SecureStorage.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/security/SecureStorage.kt
index da18a39f..23c77e5e 100644
--- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/security/SecureStorage.kt
+++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/security/SecureStorage.kt
@@ -100,9 +100,27 @@ class SecureStorage private constructor(context: Context) {
}
}
+ /**
+ * Read a credential.
+ *
+ * Returns null for both "nothing stored" and "stored but undecryptable", but
+ * treats them as distinct events. The second happens after a backup restore
+ * or a device-to-device transfer: SharedPreferences travel, the Android
+ * Keystore key that encrypted them never does, so the ciphertext can never
+ * be read again on this install. That blob is discarded here rather than
+ * left to fail on every subsequent read, which turns a permanently broken
+ * credential into a clean logged-out state. (The app also declares
+ * allowBackup="false" plus data-extraction rules so this should no longer
+ * arise - this is the belt to that manifest's braces.)
+ */
fun getCredential(key: String): String? {
- try {
- val encoded = prefs.getString(key, null) ?: return null
+ val encoded = prefs.getString(key, null)
+ if (encoded == null) {
+ Log.d(TAG, "No credential stored for: $key")
+ return null
+ }
+
+ return try {
val combined = Base64.decode(encoded, Base64.DEFAULT)
// Extract IV (first 12 bytes for GCM)
@@ -114,10 +132,16 @@ class SecureStorage private constructor(context: Context) {
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val decrypted = cipher.doFinal(encrypted)
- return String(decrypted, Charsets.UTF_8)
+ String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
- Log.e(TAG, "Failed to get credential: $key", e)
- return null
+ Log.w(
+ TAG,
+ "Credential '$key' is present but cannot be decrypted; discarding it and " +
+ "reporting no credential. Signing in again will store a fresh one.",
+ e
+ )
+ prefs.edit().remove(key).apply()
+ null
}
}
diff --git a/src-tauri/android/src/main/res/xml/data_extraction_rules.xml b/src-tauri/android/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..ed5d19db
--- /dev/null
+++ b/src-tauri/android/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs
index 38585776..0032dd20 100644
--- a/src-tauri/src/credentials.rs
+++ b/src-tauri/src/credentials.rs
@@ -471,6 +471,19 @@ impl CredentialStore {
hasher.finalize().into()
}
+ /// Load and decrypt the credential map.
+ ///
+ /// A file that is present but **undecryptable** is deliberately reported as
+ /// an *empty* credential set rather than as an error. The key never leaves
+ /// the device it was derived on (Android Keystore keys are never backed up,
+ /// and the file fallback's key is derived from machine identifiers), so a
+ /// restored/transferred install gets ciphertext with no key and every read
+ /// would fail *permanently*. Surfacing that as an error made session restore
+ /// throw instead of falling back to the login screen: an unrecoverable app
+ /// rather than a clean logged-out one. The next successful login re-encrypts
+ /// the file with the current key, so the state self-heals.
+ ///
+ /// TRACES: UR-012 | IR-014
fn load_credentials_file(&self) -> Result {
if !self.credentials_path.exists() {
return Ok(serde_json::json!({}));
@@ -483,8 +496,31 @@ impl CredentialStore {
return Ok(serde_json::json!({}));
}
- let decrypted = self.decrypt(&encrypted_data)?;
- serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
+ let decrypted = match self.decrypt(&encrypted_data) {
+ Ok(decrypted) => decrypted,
+ Err(e) => {
+ warn!(
+ "Credentials file at {:?} exists but cannot be decrypted ({}); \
+ treating as no stored credentials. This is expected after a \
+ backup restore or device transfer - the encryption key does \
+ not travel with the data. Signing in again will rewrite it.",
+ self.credentials_path, e
+ );
+ return Ok(serde_json::json!({}));
+ }
+ };
+
+ match serde_json::from_str(&decrypted) {
+ Ok(value) => Ok(value),
+ Err(e) => {
+ warn!(
+ "Credentials file at {:?} decrypted to invalid JSON ({}); \
+ treating as no stored credentials.",
+ self.credentials_path, e
+ );
+ Ok(serde_json::json!({}))
+ }
+ }
}
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
@@ -856,6 +892,73 @@ pub use android_keystore::{
mod tests {
use super::*;
+ /// Build a store pinned to the encrypted-file backend with an explicit key,
+ /// so a test can simulate "same file, different machine key" (which is what
+ /// a restored backup looks like).
+ fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
+ CredentialStore {
+ using_keyring: false,
+ credentials_path,
+ encryption_key,
+ }
+ }
+
+ /// A credentials file we cannot decrypt must read as *no credentials stored*,
+ /// not as a hard error. This is the restored-backup case: the ciphertext comes
+ /// back but the key that encrypted it (Android Keystore / the machine-derived
+ /// key) does not, so every read fails forever.
+ ///
+ /// TRACES: UR-012 | IR-014
+ #[test]
+ fn undecryptable_credentials_file_reads_as_not_found() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join(CREDENTIALS_FILENAME);
+
+ let original = file_backed_store(path.clone(), [1u8; 32]);
+ original.save_to_file("user-1", "token-abc").unwrap();
+
+ // Restored onto a device whose derived key differs: same bytes, no key.
+ let restored = file_backed_store(path.clone(), [2u8; 32]);
+ match restored.get_token("user-1") {
+ Err(CredentialError::NotFound) => {}
+ other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
+ }
+ }
+
+ /// Garbage in the file (truncation, partial restore) is the same story.
+ ///
+ /// TRACES: UR-012 | IR-014
+ #[test]
+ fn corrupt_credentials_file_reads_as_not_found() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join(CREDENTIALS_FILENAME);
+ fs::write(&path, "not base64 at all !!!").unwrap();
+
+ let store = file_backed_store(path, [3u8; 32]);
+ match store.get_token("user-1") {
+ Err(CredentialError::NotFound) => {}
+ other => panic!("expected NotFound for corrupt file, got {other:?}"),
+ }
+ }
+
+ /// …and the logged-out state must be recoverable: signing in again has to be
+ /// able to write over the unreadable file rather than failing on load.
+ ///
+ /// TRACES: UR-012 | IR-014
+ #[test]
+ fn login_after_undecryptable_file_rewrites_it() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join(CREDENTIALS_FILENAME);
+
+ let original = file_backed_store(path.clone(), [1u8; 32]);
+ original.save_to_file("user-1", "token-abc").unwrap();
+
+ let restored = file_backed_store(path.clone(), [2u8; 32]);
+ restored.save_to_file("user-1", "token-fresh").unwrap();
+
+ assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
+ }
+
#[test]
fn test_encryption_roundtrip() {
let store = CredentialStore::new();