merge: stop backing up credentials no key can ever open (B2, B4, B5)
allowBackup=false plus data_extraction_rules covering device-transfer, not just cloud-backup; treat an undecryptable credential blob as a logout rather than a hard error; drop the half-declared leanback/TV entries; jvmTarget 1.8 -> 17.
This commit is contained in:
@@ -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<serde_json::Value, CredentialError> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user