diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index fed95e2..b84712c 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -26,6 +26,9 @@ tokio = { workspace = true } [target.'cfg(not(target_os = "android"))'.dependencies] keyring-core = { workspace = true } +[target.'cfg(target_os = "android")'.dependencies] +jni = { workspace = true } + [dev-dependencies] solitaire_server = { path = "../solitaire_server" } solitaire_sync = { workspace = true } diff --git a/solitaire_data/src/android_keystore.rs b/solitaire_data/src/android_keystore.rs new file mode 100644 index 0000000..457ac40 --- /dev/null +++ b/solitaire_data/src/android_keystore.rs @@ -0,0 +1,409 @@ +/// Android Keystore token storage via JNI. +/// +/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a +/// device-bound key from the Android Keystore, and written atomically to +/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`. +/// +/// The Keystore key survives app restarts but is destroyed on uninstall (or if +/// the user changes biometric/lock credentials, in which case decryption fails +/// and we surface `TokenError::KeychainUnavailable` so the caller knows to +/// prompt re-login — identical semantics to a Linux box without Secret Service). +/// +/// Only compiled and linked on `target_os = "android"`. +use jni::{ + objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned}, + JNIEnv, JavaVM, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::auth_tokens::TokenError; + +const KEY_ALIAS: &str = "solitaire_quest_token_key"; + +#[derive(Serialize, Deserialize)] +struct TokenBlob { + username: String, + access_token: String, + refresh_token: String, +} + +// --------------------------------------------------------------------------- +// JVM helper +// --------------------------------------------------------------------------- + +fn with_jvm(f: F) -> Result +where + F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result, +{ + let app = bevy::android::ANDROID_APP + .get() + .ok_or_else(|| TokenError::KeychainUnavailable("ANDROID_APP not initialised".into()))?; + + // SAFETY: vm_as_ptr() is the process-wide JavaVM* set by the Android runtime. + let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } + .map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?; + + let mut env = vm + .attach_current_thread_permanently() + .map_err(|e| TokenError::Keyring(format!("attach: {e}")))?; + + f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}"))) +} + +// --------------------------------------------------------------------------- +// Keystore key management +// --------------------------------------------------------------------------- + +/// Load the existing AES key from the Android Keystore, or generate one if it +/// doesn't exist yet. Returns a local reference valid for the current JNI frame. +fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result> { + // KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); ks.load(null); + let ks_class = env.find_class("java/security/KeyStore")?; + let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?); + let ks = env + .call_static_method( + &ks_class, + "getInstance", + "(Ljava/lang/String;)Ljava/security/KeyStore;", + &[ks_type.borrow()], + )? + .l()?; + + let null = JObject::null(); + env.call_method( + &ks, + "load", + "(Ljava/security/KeyStore$LoadStoreParameter;)V", + &[JValue::Object(&null)], + )? + .v()?; + + // Key key = ks.getKey(ALIAS, null) — char[] password is null for hardware keys + let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?); + let null2 = JObject::null(); + let key = env + .call_method( + &ks, + "getKey", + "(Ljava/lang/String;[C)Ljava/security/Key;", + &[alias.borrow(), JValue::Object(&null2)], + )? + .l()?; + + if !env.is_same_object(&key, JObject::null())? { + return Ok(key); + } + + // No key yet — generate AES-256 with GCM block mode. + let builder_class = + env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?; + let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?); + // PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3 + let purpose = JValueOwned::Int(3); + let builder = env.new_object( + &builder_class, + "(Ljava/lang/String;I)V", + &[alias2.borrow(), purpose.borrow()], + )?; + + let str_class = env.find_class("java/lang/String")?; + + // builder.setBlockModes(["GCM"]) + let gcm_str = env.new_string("GCM")?; + let block_modes: JObjectArray = env.new_object_array(1, &str_class, &gcm_str)?; + let block_modes_val = JValueOwned::Object(block_modes.into()); + let builder = env + .call_method( + &builder, + "setBlockModes", + "([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[block_modes_val.borrow()], + )? + .l()?; + + // builder.setEncryptionPaddings(["NoPadding"]) + let nopad_str = env.new_string("NoPadding")?; + let enc_pads: JObjectArray = env.new_object_array(1, &str_class, &nopad_str)?; + let enc_pads_val = JValueOwned::Object(enc_pads.into()); + let builder = env + .call_method( + &builder, + "setEncryptionPaddings", + "([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[enc_pads_val.borrow()], + )? + .l()?; + + // KeyGenParameterSpec spec = builder.build() + let spec = env + .call_method( + &builder, + "build", + "()Landroid/security/keystore/KeyGenParameterSpec;", + &[], + )? + .l()?; + + // KeyGenerator kg = KeyGenerator.getInstance("AES", "AndroidKeyStore") + let kg_class = env.find_class("javax/crypto/KeyGenerator")?; + let aes = JValueOwned::from(env.new_string("AES")?); + let ks_name = JValueOwned::from(env.new_string("AndroidKeyStore")?); + let kg = env + .call_static_method( + &kg_class, + "getInstance", + "(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;", + &[aes.borrow(), ks_name.borrow()], + )? + .l()?; + + // kg.init(spec); return kg.generateKey() + let spec_val = JValueOwned::Object(spec); + env.call_method( + &kg, + "init", + "(Ljava/security/spec/AlgorithmParameterSpec;)V", + &[spec_val.borrow()], + )? + .v()?; + + env.call_method(&kg, "generateKey", "()Ljavax/crypto/SecretKey;", &[])? + .l() +} + +// --------------------------------------------------------------------------- +// AES-GCM encrypt / decrypt +// --------------------------------------------------------------------------- + +/// Returns `[12-byte IV][ciphertext+GCM-tag]`. +fn encrypt_gcm( + env: &mut JNIEnv<'_>, + key: &JObject<'_>, + plaintext: &[u8], +) -> jni::errors::Result> { + let cipher_class = env.find_class("javax/crypto/Cipher")?; + let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?); + let cipher = env + .call_static_method( + &cipher_class, + "getInstance", + "(Ljava/lang/String;)Ljavax/crypto/Cipher;", + &[transform.borrow()], + )? + .l()?; + + // cipher.init(Cipher.ENCRYPT_MODE=1, key) + let mode = JValueOwned::Int(1); + env.call_method( + &cipher, + "init", + "(ILjava/security/Key;)V", + &[mode.borrow(), JValue::Object(key)], + )? + .v()?; + + // IV is generated by Android's provider; read it back after init. + let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?; + // SAFETY: the method signature guarantees a byte array return. + let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) }; + let iv = env.convert_byte_array(&iv_arr)?; + + let pt_arr = env.byte_array_from_slice(plaintext)?; + let pt_val = JValueOwned::Object(pt_arr.into()); + let ct_jobj = env + .call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])? + .l()?; + // SAFETY: doFinal([B) returns [B. + let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) }; + let ciphertext = env.convert_byte_array(&ct_arr)?; + + let mut out = Vec::with_capacity(iv.len() + ciphertext.len()); + out.extend_from_slice(&iv); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Expects `data` as `[12-byte IV][ciphertext+GCM-tag]`. +fn decrypt_gcm( + env: &mut JNIEnv<'_>, + key: &JObject<'_>, + data: &[u8], +) -> jni::errors::Result> { + let (iv, ciphertext) = data.split_at(12); + + let cipher_class = env.find_class("javax/crypto/Cipher")?; + let transform = JValueOwned::from(env.new_string("AES/GCM/NoPadding")?); + let cipher = env + .call_static_method( + &cipher_class, + "getInstance", + "(Ljava/lang/String;)Ljavax/crypto/Cipher;", + &[transform.borrow()], + )? + .l()?; + + // GCMParameterSpec spec = new GCMParameterSpec(128, iv) + let spec_class = env.find_class("javax/crypto/spec/GCMParameterSpec")?; + let tag_len = JValueOwned::Int(128); + let iv_arr = env.byte_array_from_slice(iv)?; + let iv_val = JValueOwned::Object(iv_arr.into()); + let spec = env.new_object( + &spec_class, + "(I[B)V", + &[tag_len.borrow(), iv_val.borrow()], + )?; + + // cipher.init(Cipher.DECRYPT_MODE=2, key, spec) + let mode = JValueOwned::Int(2); + let spec_val = JValueOwned::Object(spec); + env.call_method( + &cipher, + "init", + "(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V", + &[mode.borrow(), JValue::Object(key), spec_val.borrow()], + )? + .v()?; + + let ct_arr = env.byte_array_from_slice(ciphertext)?; + let ct_val = JValueOwned::Object(ct_arr.into()); + let pt_jobj = env + .call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])? + .l()?; + // SAFETY: doFinal([B) returns [B. + let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) }; + env.convert_byte_array(&pt_arr) +} + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +fn token_file_path() -> Option { + crate::platform::data_dir().map(|d| d.join("auth_tokens.bin")) +} + +fn read_file_bytes() -> Result, TokenError> { + let path = token_file_path() + .ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; + if !path.exists() { + return Err(TokenError::NotFound(String::new())); + } + std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}"))) +} + +fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> { + let path = token_file_path() + .ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data) + .map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?; + std::fs::rename(&tmp, &path) + .map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}"))) +} + +fn load_blob(username: &str) -> Result { + let data = read_file_bytes().map_err(|e| match e { + TokenError::NotFound(_) => TokenError::NotFound(username.to_string()), + other => other, + })?; + + if data.len() < 12 { + return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into())); + } + + let plaintext = with_jvm(|env| { + let key = load_or_create_key(env)?; + decrypt_gcm(env, &key, &data) + })?; + + let blob: TokenBlob = serde_json::from_slice(&plaintext) + .map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?; + + if blob.username != username { + return Err(TokenError::NotFound(username.to_string())); + } + + Ok(blob) +} + +// --------------------------------------------------------------------------- +// Public API — mirrors auth_tokens desktop surface exactly. +// --------------------------------------------------------------------------- + +/// Encrypt and store `access_token` and `refresh_token` for `username`. +/// +/// Overwrites any previously stored tokens. +pub fn store_tokens( + username: &str, + access_token: &str, + refresh_token: &str, +) -> Result<(), TokenError> { + let blob = TokenBlob { + username: username.to_string(), + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + }; + let plaintext = serde_json::to_vec(&blob) + .map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?; + + let encrypted = with_jvm(|env| { + let key = load_or_create_key(env)?; + encrypt_gcm(env, &key, &plaintext) + })?; + + write_file_bytes(&encrypted) +} + +/// Return the stored access token for `username`. +/// +/// Returns [`TokenError::NotFound`] if no token has been stored yet. +pub fn load_access_token(username: &str) -> Result { + load_blob(username).map(|b| b.access_token) +} + +/// Return the stored refresh token for `username`. +/// +/// Returns [`TokenError::NotFound`] if no token has been stored yet. +pub fn load_refresh_token(username: &str) -> Result { + load_blob(username).map(|b| b.refresh_token) +} + +/// Delete stored tokens and remove the Keystore key for `username`. +/// +/// Missing file or missing Keystore entry are silently ignored. +pub fn delete_tokens(_username: &str) -> Result<(), TokenError> { + if let Some(path) = token_file_path() { + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?; + } + } + + // Remove the Keystore key so a future re-login generates a fresh key. + with_jvm(|env| { + let ks_class = env.find_class("java/security/KeyStore")?; + let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?); + let ks = env + .call_static_method( + &ks_class, + "getInstance", + "(Ljava/lang/String;)Ljava/security/KeyStore;", + &[ks_type.borrow()], + )? + .l()?; + + let null = JObject::null(); + env.call_method( + &ks, + "load", + "(Ljava/security/KeyStore$LoadStoreParameter;)V", + &[JValue::Object(&null)], + )? + .v()?; + + let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?); + env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])? + .v() + }) +} diff --git a/solitaire_data/src/auth_tokens.rs b/solitaire_data/src/auth_tokens.rs index 84ef6f2..d638e29 100644 --- a/solitaire_data/src/auth_tokens.rs +++ b/solitaire_data/src/auth_tokens.rs @@ -131,35 +131,29 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> { } // ------------------------------------------------------------------- -// Android stub — same public API, always returns KeychainUnavailable. -// Lets `sync_client::*` compile unchanged on Android; the runtime -// effect is "session login required every launch", same as a Linux -// box without Secret Service. +// Android — delegate to the JNI Keystore bridge in android_keystore. // ------------------------------------------------------------------- -#[cfg(target_os = "android")] -const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)"; - #[cfg(target_os = "android")] pub fn store_tokens( - _username: &str, - _access_token: &str, - _refresh_token: &str, + username: &str, + access_token: &str, + refresh_token: &str, ) -> Result<(), TokenError> { - Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) + crate::android_keystore::store_tokens(username, access_token, refresh_token) } #[cfg(target_os = "android")] -pub fn load_access_token(_username: &str) -> Result { - Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +pub fn load_access_token(username: &str) -> Result { + crate::android_keystore::load_access_token(username) } #[cfg(target_os = "android")] -pub fn load_refresh_token(_username: &str) -> Result { - Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +pub fn load_refresh_token(username: &str) -> Result { + crate::android_keystore::load_refresh_token(username) } #[cfg(target_os = "android")] -pub fn delete_tokens(_username: &str) -> Result<(), TokenError> { - Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +pub fn delete_tokens(username: &str) -> Result<(), TokenError> { + crate::android_keystore::delete_tokens(username) } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index b952a3e..95f1468 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -138,6 +138,9 @@ pub use weekly::{ pub mod challenge; pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; +pub mod difficulty_seeds; +pub use difficulty_seeds::{seeds_for, DifficultySeeds}; + pub mod settings; pub use settings::{ load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, @@ -147,6 +150,9 @@ pub use settings::{ TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, }; +#[cfg(target_os = "android")] +mod android_keystore; + pub mod auth_tokens; pub use auth_tokens::{ delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,