Skip to content

Commit efef2f4

Browse files
authored
Merge pull request #3253 from wgqqqqq/wgq/mobile-login-reliability
fix(mobile): make account sign-in recovery reliable
2 parents 62db7fc + a21107e commit efef2f4

5 files changed

Lines changed: 193 additions & 17 deletions

File tree

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
4141
}
4242

4343
async aboutToAppear(onAccountRestored?: (signedIn: boolean) => void): Promise<void> {
44+
// Account login can be tapped while the rest of the cold-start work is
45+
// still running. Initialize its secure store first so the login path never
46+
// races the asynchronous cache and onboarding setup below.
47+
await this.settingsController.initializeCloudAccount(this.host.context());
4448
await this.localeController.initialize(this.host.context());
4549
if (this.host.offerTaskNotifications) {
4650
await this.taskCompletionNotificationPort.offerOnboarding(
@@ -51,7 +55,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
5155
await this.remoteChatCache.init(this.host.context());
5256
await this.remoteSessionListCache.init(this.host.context());
5357
await this.restoreCachedRemoteSessions();
54-
await this.settingsController.initializeCloudAccount(this.host.context());
5558
if (onAccountRestored) onAccountRestored(this.settingsController.hasCloudAccountSession());
5659
if (this.settingsController.hasCloudAccountSession()) {
5760
try {

‎src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface PreferredCloudTarget {
6464
/** Owns the authenticated account and remote device selection. */
6565
export class SettingsController {
6666
private readonly cloud?: CloudAccountSettingsDependencies;
67+
private cloudInitialization?: Promise<void>;
6768
private cloudSession?: CloudAccountSession;
6869
private cloudRelayUrl: string = '';
6970
private selectedRelayUrl: string = DEFAULT_CLOUD_RELAY_URL;
@@ -89,6 +90,13 @@ export class SettingsController {
8990
}
9091

9192
async initializeCloudAccount(context: Context): Promise<void> {
93+
if (!this.cloudInitialization) {
94+
this.cloudInitialization = this.initializeCloudAccountInternal(context);
95+
}
96+
await this.cloudInitialization;
97+
}
98+
99+
private async initializeCloudAccountInternal(context: Context): Promise<void> {
92100
const cloud = this.requireCloud();
93101
await cloud.sessionStore.init(context);
94102
await this.restoreCloudAccountSession();
@@ -160,8 +168,17 @@ export class SettingsController {
160168
const relayUrl = this.selectedRelayUrl;
161169
const generation = ++this.accountLoginVersion;
162170
const cloud = this.requireCloud();
171+
if (this.cloudInitialization) {
172+
await this.cloudInitialization;
173+
}
163174
RemoteLogger.info('cloud account UI login requested');
164-
const deviceSecret = await cloud.sessionStore.deviceSecret();
175+
let deviceSecret: Uint8Array;
176+
try {
177+
deviceSecret = await cloud.sessionStore.deviceSecret();
178+
} catch (err) {
179+
RemoteLogger.error(`cloud account device identity failed: ${err instanceof Error ? err.message : String(err)}`);
180+
throw err instanceof Error ? err : new Error(String(err));
181+
}
165182
if (generation !== this.accountLoginVersion) { deviceSecret.fill(0); throw new Error('Account login cancelled'); }
166183
let session: CloudAccountSession;
167184
try {

‎src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { preferences } from '@kit.ArkData';
44
import { Encoding } from './Encoding';
55
import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract';
66
import { OhosError } from './OhosError';
7+
import { RemoteLogger } from './RemoteLogger';
78

89
const STORE_NAME: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_STORE;
910
const CIPHER_KEY: string = 'github_device_session_v1_cipher';
@@ -49,10 +50,33 @@ export class CloudAccountSessionStore {
4950
const previous = await this.load();
5051
const secret = previous ? Encoding.base64ToBytes(previous.masterKey) : Encoding.randomBytes(32);
5152
if (secret.length !== 32) throw new Error('Stored device identity is invalid.');
52-
await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret));
53+
try {
54+
await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret));
55+
} catch (err) {
56+
// A reinstall clears Preferences but HarmonyOS may retain the HUKS alias.
57+
// Only rotate the alias when there is no prior session record: existing
58+
// encrypted account data must never be made unreadable as recovery.
59+
if (previous) throw err instanceof Error ? err : new Error(OhosError.reason(err));
60+
RemoteLogger.warn('cloud account device identity write failed on fresh install; recreating local key');
61+
await this.recreateKeyForFreshInstall();
62+
await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret));
63+
}
5364
return secret;
5465
}
5566

67+
private async recreateKeyForFreshInstall(): Promise<void> {
68+
try {
69+
await huks.deleteKeyItem(HUKS_ALIAS, {});
70+
} catch (err) {
71+
const message = OhosError.reason(err);
72+
if (message.indexOf('12000011') < 0 && message.indexOf('-13') < 0 &&
73+
message.toLowerCase().indexOf('does not exist') < 0) {
74+
throw err instanceof Error ? err : new Error(OhosError.reason(err));
75+
}
76+
}
77+
await this.ensureKey();
78+
}
79+
5680
async loadProfile(userId: string): Promise<GitHubAccountProfile | undefined> {
5781
const text = await this.readSealed(`github_profile_${userId}_v1_cipher`, `github_profile_${userId}_v1_iv`);
5882
return readCachedGitHubProfile(userId, text || '');

‎src/apps/mobile/shared/core-persistence/src/androidMain/kotlin/com/openbitfun/mobile/core/persistence/AndroidSecureStore.kt‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import android.security.keystore.KeyGenParameterSpec
55
import android.security.keystore.KeyProperties
66
import android.util.Base64
7+
import android.util.Log
78
import java.security.KeyStore
89
import javax.crypto.Cipher
910
import javax.crypto.KeyGenerator
@@ -31,11 +32,17 @@ private class AndroidSecureStore(
3132
}
3233

3334
override fun write(key: String, value: ByteArray) {
34-
val cipher = Cipher.getInstance(TRANSFORMATION)
35-
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
36-
val packed = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." +
37-
Base64.encodeToString(cipher.doFinal(value), Base64.NO_WRAP)
38-
check(preferences.edit().putString(key, packed).commit()) { "Secure value could not be persisted." }
35+
try {
36+
writeWithKey(key, value)
37+
} catch (cause: Throwable) {
38+
// Android may retain a Keystore alias while uninstalling the app
39+
// clears SharedPreferences. Replacing the alias is safe only when
40+
// this namespace has no encrypted records to preserve.
41+
if (preferences.all.isNotEmpty()) throw cause
42+
Log.w(TAG, "Secure store key failed on a fresh install; recreating it", cause)
43+
deleteKeyAlias()
44+
writeWithKey(key, value)
45+
}
3946
}
4047

4148
override fun delete(key: String) {
@@ -59,9 +66,23 @@ private class AndroidSecureStore(
5966
return generator.generateKey()
6067
}
6168

69+
private fun writeWithKey(key: String, value: ByteArray) {
70+
val cipher = Cipher.getInstance(TRANSFORMATION)
71+
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
72+
val packed = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." +
73+
Base64.encodeToString(cipher.doFinal(value), Base64.NO_WRAP)
74+
check(preferences.edit().putString(key, packed).commit()) { "Secure value could not be persisted." }
75+
}
76+
77+
private fun deleteKeyAlias() {
78+
val keyStore = KeyStore.getInstance(KEY_STORE).also { it.load(null) }
79+
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
80+
}
81+
6282
companion object {
6383
private const val KEY_STORE = "AndroidKeyStore"
6484
private const val TRANSFORMATION = "AES/GCM/NoPadding"
85+
private const val TAG = "OpenBitFunSecureStore"
6586
}
6687
}
6788

‎src/crates/services/relay-service/src/identity.rs‎

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
44
use axum::http::StatusCode;
55
use serde::Deserialize;
6-
use std::{sync::Arc, time::Duration};
6+
use std::{
7+
collections::HashMap,
8+
sync::{Arc, Mutex, OnceLock},
9+
time::{Duration, SystemTime, UNIX_EPOCH},
10+
};
711

812
pub(crate) const IDENTITY_ME_URL: &str = "https://auth.openbitfun.com/api/v1/me";
913

@@ -27,6 +31,63 @@ struct IdentityResponse {
2731
user: VerifiedIdentity,
2832
}
2933

34+
/// A completed poll response kept long enough to make the authority's
35+
/// single-use transaction safe to retry after a lost HTTP response.
36+
struct CompletedPoll {
37+
payload: serde_json::Value,
38+
expires_at: i64,
39+
}
40+
41+
/// Terminal sign-in outcomes are keyed by both transaction id and secret.
42+
/// A different secret must never overwrite the valid client's replay entry.
43+
fn completed_authorizations() -> &'static Mutex<HashMap<(String, String), CompletedPoll>> {
44+
static COMPLETED: OnceLock<Mutex<HashMap<(String, String), CompletedPoll>>> = OnceLock::new();
45+
COMPLETED.get_or_init(|| Mutex::new(HashMap::new()))
46+
}
47+
48+
const POLL_REPLAY_SECS: i64 = 900;
49+
50+
fn now_secs() -> i64 {
51+
SystemTime::now()
52+
.duration_since(UNIX_EPOCH)
53+
.unwrap_or_default()
54+
.as_secs() as i64
55+
}
56+
57+
fn replay_completed_poll(
58+
transaction_id: &str,
59+
transaction_secret: &str,
60+
) -> Option<serde_json::Value> {
61+
let key = (transaction_id.to_string(), transaction_secret.to_string());
62+
let mut completed = completed_authorizations()
63+
.lock()
64+
.unwrap_or_else(|poisoned| poisoned.into_inner());
65+
completed.retain(|_, poll| poll.expires_at > now_secs());
66+
completed.get(&key).map(|poll| poll.payload.clone())
67+
}
68+
69+
fn remember_completed_poll(
70+
transaction_id: &str,
71+
transaction_secret: &str,
72+
payload: &serde_json::Value,
73+
) {
74+
if payload.get("status").and_then(|status| status.as_str()) == Some("pending") {
75+
return;
76+
}
77+
let key = (transaction_id.to_string(), transaction_secret.to_string());
78+
let mut completed = completed_authorizations()
79+
.lock()
80+
.unwrap_or_else(|poisoned| poisoned.into_inner());
81+
completed.retain(|_, poll| poll.expires_at > now_secs());
82+
completed.insert(
83+
key,
84+
CompletedPoll {
85+
payload: payload.clone(),
86+
expires_at: now_secs() + POLL_REPLAY_SECS,
87+
},
88+
);
89+
}
90+
3091
impl IdentityVerifier {
3192
pub(crate) async fn start_auth(
3293
&self,
@@ -55,14 +116,20 @@ impl IdentityVerifier {
55116
{
56117
return Err(StatusCode::BAD_REQUEST);
57118
}
58-
self.auth_request(
59-
"auth/desktop/poll",
60-
serde_json::json!({
61-
"transactionId": transaction_id,
62-
"transactionSecret": transaction_secret,
63-
}),
64-
)
65-
.await
119+
if let Some(payload) = replay_completed_poll(transaction_id, transaction_secret) {
120+
return Ok(payload);
121+
}
122+
let payload = self
123+
.auth_request(
124+
"auth/desktop/poll",
125+
serde_json::json!({
126+
"transactionId": transaction_id,
127+
"transactionSecret": transaction_secret,
128+
}),
129+
)
130+
.await?;
131+
remember_completed_poll(transaction_id, transaction_secret, &payload);
132+
Ok(payload)
66133
}
67134

68135
async fn auth_request(
@@ -279,6 +346,50 @@ mod tests {
279346
task.abort();
280347
}
281348
}
349+
350+
#[tokio::test]
351+
async fn poll_replays_a_completed_transaction_after_a_mismatched_secret() {
352+
use std::sync::atomic::{AtomicUsize, Ordering};
353+
354+
let upstream_calls = Arc::new(AtomicUsize::new(0));
355+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
356+
let transaction_id = format!("replayed-{}", listener.local_addr().unwrap().port());
357+
let url = format!("http://{}/me", listener.local_addr().unwrap());
358+
let counter = upstream_calls.clone();
359+
let app = Router::new().route(
360+
"/auth/desktop/poll",
361+
axum::routing::post(move || {
362+
let counter = counter.clone();
363+
async move {
364+
if counter.fetch_add(1, Ordering::SeqCst) == 0 {
365+
axum::Json(serde_json::json!({
366+
"status": "authorized",
367+
"tokens": { "accessToken": "token-1" },
368+
}))
369+
} else {
370+
axum::Json(serde_json::json!({ "status": "consumed" }))
371+
}
372+
}
373+
}),
374+
);
375+
let task = tokio::spawn(async move {
376+
axum::serve(listener, app).await.unwrap();
377+
});
378+
let client = IdentityVerifier::with_url(&url).unwrap();
379+
380+
let first = client.poll_auth(&transaction_id, "secret-1").await.unwrap();
381+
let repeated = client.poll_auth(&transaction_id, "secret-1").await.unwrap();
382+
let other_secret = client.poll_auth(&transaction_id, "secret-2").await.unwrap();
383+
let repeated_after_other_secret =
384+
client.poll_auth(&transaction_id, "secret-1").await.unwrap();
385+
386+
assert_eq!(first["status"], "authorized");
387+
assert_eq!(repeated, first);
388+
assert_eq!(other_secret["status"], "consumed");
389+
assert_eq!(repeated_after_other_secret, first);
390+
assert_eq!(upstream_calls.load(Ordering::SeqCst), 2);
391+
task.abort();
392+
}
282393
}
283394

284395
fn identity_request_permit() -> Result<tokio::sync::OwnedSemaphorePermit, StatusCode> {

0 commit comments

Comments
 (0)