Skip to content

Commit 888965f

Browse files
authored
Merge pull request #3295 from wgqqqqq/wgq/harmony-watch-login-recovery
fix(harmonyos): recover watch provisioning after interrupted login
2 parents 09c78fd + 33595b1 commit 888965f

10 files changed

Lines changed: 315 additions & 22 deletions

File tree

‎src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,8 @@ export const EN_US_MESSAGES: [string, string][] = [
683683
['watchProvision.busy', 'The phone is handling another device request. Try again later.'],
684684
['watchProvision.errors.noDesktop', 'Sign in with email or GitHub on the phone, or connect a desktop signed in to the same account.'],
685685
['watchProvision.errors.accountUnavailable', 'The account could not be verified. Check the phone network and try again.'],
686+
['watchProvision.errors.requestExpired', 'The watch sign-in request expired. Start sign-in again on the watch.'],
687+
['watchProvision.errors.alreadyRegistered', 'This watch is registered, but this phone has no recoverable credential. Revoke the old watch authorization and retry.'],
686688
['watchProvision.errors.desktopUnreachable', 'The desktop could not be reached. Make sure it is online and try again.'],
687689
['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same OpenBitFun account.'],
688690
['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'],

‎src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [
683683
['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'],
684684
['watchProvision.errors.noDesktop', '请先在手机上使用邮箱或 GitHub 登录,或连接已登录同一账号的桌面端。'],
685685
['watchProvision.errors.accountUnavailable', '暂时无法验证账号,请检查手机网络后重试。'],
686+
['watchProvision.errors.requestExpired', '手表登录请求已过期,请在手表上重新发起登录。'],
687+
['watchProvision.errors.alreadyRegistered', '这块手表已注册,但本机没有可恢复的授权凭据。请撤销该手表的旧授权后重试。'],
686688
['watchProvision.errors.desktopUnreachable', '暂时无法连接桌面端,请确认桌面端在线后重试。'],
687689
['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 OpenBitFun 账号。'],
688690
['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'],

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition {
7777
* mints the watch's credential itself in that case, so waiting for a live
7878
* connection would keep the listener down exactly when it is not needed.
7979
*/
80-
private async startWatchProvisioning(): Promise<void> {
80+
protected async startWatchProvisioning(): Promise<void> {
8181
if (!this.settingsController.hasCloudAccountSession() && !this.hasRemoteBindingForResume()) {
8282
return;
8383
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export abstract class AppRootRuntimeComposition {
121121
abstract failRemoteConnection(err: Object): void;
122122
abstract handleNavigationBack(route: AppRoute): boolean;
123123
abstract hasRemoteBindingForResume(): boolean;
124+
protected abstract startWatchProvisioning(): Promise<void>;
124125
abstract isRemoteConversationContext(sessionId: string): boolean;
125126
abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[];
126127
abstract loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise<void>;
@@ -894,6 +895,9 @@ export abstract class AppRootRuntimeComposition {
894895
cloudCancelLogin: (): void => this.settingsController.cancelCloudLogin(),
895896
cloudLogin: async (): Promise<string> => {
896897
const user = await this.settingsController.loginCloudAccount();
898+
// Returning from authorization can show the page before the account
899+
// session is saved. Start listening after login completes as well.
900+
await this.startWatchProvisioning();
897901
const pending = this.pendingAccountDeviceId;
898902
this.pendingAccountDeviceId = '';
899903
if (pending.length > 0) {

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,23 +135,38 @@ export class SettingsController {
135135
return undefined;
136136
}
137137
const cloud = this.requireCloud();
138+
// Persist the identity before registration. A timed-out or cancelled
139+
// handoff can then replay registration without rotating the watch's key.
140+
let identity = await cloud.sessionStore.loadWatchProvisionIdentity(this.cloudRelayUrl, session.userId, deviceId);
141+
if (!identity) {
142+
identity = {
143+
relayUrl: this.cloudRelayUrl, userId: session.userId, deviceId, requestId,
144+
privateKeyBase64: Encoding.bytesToBase64(Encoding.randomBytes(32))
145+
};
146+
await cloud.sessionStore.saveWatchProvisionIdentity(identity);
147+
}
148+
const secret = Encoding.base64ToBytes(identity.privateKeyBase64);
138149
try {
139150
const provisioned = await cloud.client.provisionDevice(
140-
this.cloudRelayUrl, session, deviceId, deviceName, requestId);
151+
this.cloudRelayUrl, session, deviceId, deviceName, identity.requestId, secret);
141152
RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`);
153+
const masterKeyBase64 = Encoding.bytesToBase64(provisioned.deviceSecret);
154+
provisioned.deviceSecret.fill(0);
142155
return {
143156
ok: true,
144157
passwordRequired: false,
145158
relayUrl: this.cloudRelayUrl,
146159
token: provisioned.token,
147160
userId: provisioned.userId,
148-
masterKeyBase64: Encoding.bytesToBase64(provisioned.deviceSecret),
161+
masterKeyBase64,
149162
deviceId: provisioned.deviceId,
150163
failure: '',
151164
desktopReported: false
152165
};
153166
} catch (err) {
154167
throw err instanceof Error ? err : new Error('Watch credential provisioning failed.');
168+
} finally {
169+
secret.fill(0);
155170
}
156171
}
157172

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

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -370,27 +370,34 @@ export class CloudAccountClient {
370370
session: CloudAccountSession,
371371
deviceId: string,
372372
deviceName: string,
373-
requestId: string
373+
requestId: string,
374+
deviceSecret?: Uint8Array
374375
): Promise<CloudProvisionedDevice> {
375-
const keys = X25519.generateKeyPair();
376-
const body: ProvisionDeviceRequest = {
377-
public_key: Encoding.bytesToBase64(keys.publicKey),
378-
device_id: deviceId,
379-
device_name: deviceName,
380-
device_kind: DEVICE_KIND_WATCH,
381-
request_id: requestId
382-
};
383-
const wire = await this.request<ProvisionDeviceWire>(
384-
relayUrl, '/api/auth/provision-device', 'POST', body, session.token);
385-
const token = (wire.token || '').trim();
386-
const userId = (wire.user_id || '').trim();
387-
const provisionedDeviceId = (wire.device_id || '').trim();
388-
if (token.length === 0 || userId !== session.userId || provisionedDeviceId !== deviceId) {
389-
// A credential naming a different account or device would sign the watch
390-
// in as somebody else. Refuse rather than pass it on.
391-
throw new Error('Relay returned a mismatched provisioned device identity.');
376+
const privateKey = deviceSecret ? Encoding.copyBytes(deviceSecret) : X25519.generateKeyPair().privateKey;
377+
try {
378+
if (privateKey.length !== 32) throw new Error('Invalid watch device identity.');
379+
const body: ProvisionDeviceRequest = {
380+
public_key: Encoding.bytesToBase64(X25519.scalarMultBase(privateKey)),
381+
device_id: deviceId,
382+
device_name: deviceName,
383+
device_kind: DEVICE_KIND_WATCH,
384+
request_id: requestId
385+
};
386+
const wire = await this.request<ProvisionDeviceWire>(
387+
relayUrl, '/api/auth/provision-device', 'POST', body, session.token);
388+
const token = (wire.token || '').trim();
389+
const userId = (wire.user_id || '').trim();
390+
const provisionedDeviceId = (wire.device_id || '').trim();
391+
if (token.length === 0 || userId !== session.userId || provisionedDeviceId !== deviceId) {
392+
// A credential naming a different account or device would sign the watch
393+
// in as somebody else. Refuse rather than pass it on.
394+
throw new Error('Relay returned a mismatched provisioned device identity.');
395+
}
396+
return { token, userId, deviceId: provisionedDeviceId, deviceSecret: privateKey };
397+
} catch (err) {
398+
privateKey.fill(0);
399+
throw err instanceof Error ? err : new Error('Watch credential provisioning failed.');
392400
}
393-
return { token, userId, deviceId: provisionedDeviceId, deviceSecret: keys.privateKey };
394401
}
395402

396403
/**

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export interface PersistedCloudAccountSession {
2121
targetDeviceName?: string;
2222
}
2323

24+
export interface PersistedWatchProvisionIdentity {
25+
relayUrl: string;
26+
userId: string;
27+
deviceId: string;
28+
requestId: string;
29+
privateKeyBase64: string;
30+
}
31+
2432
/** Persists only an HUKS-encrypted account session, never plaintext secrets. */
2533
export class CloudAccountSessionStore {
2634
private store?: preferences.Preferences;
@@ -90,6 +98,28 @@ export class CloudAccountSessionStore {
9098
await this.writeSealed(CIPHER_KEY, IV_KEY, JSON.stringify(session));
9199
}
92100

101+
async loadWatchProvisionIdentity(relayUrl: string, userId: string,
102+
deviceId: string): Promise<PersistedWatchProvisionIdentity | undefined> {
103+
const key = this.watchProvisionKey(relayUrl, userId, deviceId);
104+
const text = await this.readSealed(`${key}_cipher`, `${key}_iv`);
105+
if (!text) return undefined;
106+
const identity = JSON.parse(text) as PersistedWatchProvisionIdentity;
107+
if (identity.relayUrl !== relayUrl || identity.userId !== userId || identity.deviceId !== deviceId ||
108+
!identity.requestId || Encoding.base64ToBytes(identity.privateKeyBase64).length !== 32) {
109+
throw new Error('Stored watch provisioning identity is invalid.');
110+
}
111+
return identity;
112+
}
113+
114+
async saveWatchProvisionIdentity(identity: PersistedWatchProvisionIdentity): Promise<void> {
115+
const key = this.watchProvisionKey(identity.relayUrl, identity.userId, identity.deviceId);
116+
await this.writeSealed(`${key}_cipher`, `${key}_iv`, JSON.stringify(identity));
117+
}
118+
119+
private watchProvisionKey(relayUrl: string, userId: string, deviceId: string): string {
120+
return `watch_provision_v1_${encodeURIComponent(relayUrl)}_${encodeURIComponent(userId)}_${deviceId}`;
121+
}
122+
93123
private async writeSealed(cipherKey: string, ivKey: string, plaintext: string): Promise<void> {
94124
try {
95125
const store = this.requireStore();

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
WatchProvisionRequest
1010
} from './WatchProvisionProtocol';
1111
import { WatchProvisionDisplay } from './WatchProvisionDisplay';
12+
import { CloudAccountRequestError } from './CloudAccountClient';
1213

1314
const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC';
1415

@@ -200,6 +201,10 @@ export class WatchProvisionController {
200201
}
201202

202203
private async runProvisioning(request: WatchProvisionRequest, password: string): Promise<boolean> {
204+
if (WatchProvisionProtocol.isExpired(request, Date.now())) {
205+
await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.requestExpired'));
206+
return true;
207+
}
203208
if (!this.port.canProvision()) {
204209
await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.noDesktop'));
205210
return true;
@@ -209,6 +214,10 @@ export class WatchProvisionController {
209214
outcome = await this.port.provision(request.deviceId, request.deviceName, request.requestId, password);
210215
} catch (err) {
211216
RemoteLogger.error(`watch provisioning failed: ${WatchProvisionController.errorText(err)}`);
217+
if (err instanceof CloudAccountRequestError && err.statusCode === 409) {
218+
await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.alreadyRegistered'));
219+
return true;
220+
}
212221
if (password.length > 0) {
213222
this.state.requirePassword(RemoteI18n.t('watchProvision.errors.passwordFailed'));
214223
return false;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const ts = require('typescript');
6+
7+
// Exercise the actual shared login action without creating native UI owners.
8+
const file = path.join(__dirname, '../../entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets');
9+
const source = fs.readFileSync(file, 'utf8');
10+
const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
11+
let initializer;
12+
function visit(node) {
13+
if (ts.isPropertyAssignment(node) && node.name.getText(ast) === 'cloudLogin') {
14+
initializer = node.initializer.getText(ast);
15+
}
16+
ts.forEachChild(node, visit);
17+
}
18+
visit(ast);
19+
assert.ok(initializer, 'shared login action exists');
20+
const js = ts.transpileModule(`const action = ${initializer};`, {
21+
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
22+
}).outputText;
23+
function action(context) {
24+
return new Function(`${js}\nreturn action;`).call(context);
25+
}
26+
27+
test('foreground restoration before login resolves still starts the watch listener after login', async () => {
28+
let resolveLogin;
29+
const calls = [];
30+
const context = {
31+
settingsController: { loginCloudAccount: () => new Promise(resolve => { resolveLogin = resolve; }) },
32+
startWatchProvisioning: async () => { calls.push('listen'); },
33+
pendingAccountDeviceId: '',
34+
};
35+
const result = action(context)();
36+
assert.deepEqual(calls, []);
37+
resolveLogin('account');
38+
assert.equal(await result, 'account');
39+
assert.deepEqual(calls, ['listen']);
40+
});
41+
42+
test('watch listener starts before resuming a pending desktop link', async () => {
43+
const calls = [];
44+
const context = {
45+
settingsController: { loginCloudAccount: async () => 'account' },
46+
startWatchProvisioning: async () => { calls.push('listen'); },
47+
pendingAccountDeviceId: 'desktop',
48+
connectAccountDeviceLink: async id => { calls.push(id); },
49+
};
50+
assert.equal(await action(context)(), 'account');
51+
assert.deepEqual(calls, ['listen', 'desktop']);
52+
assert.equal(context.pendingAccountDeviceId, '');
53+
});
54+
55+
test('cancelled or failed account login does not start provisioning or consume a pending link', async () => {
56+
let started = false;
57+
const context = {
58+
settingsController: { loginCloudAccount: async () => { throw new Error('cancelled'); } },
59+
startWatchProvisioning: async () => { started = true; },
60+
pendingAccountDeviceId: 'desktop',
61+
};
62+
await assert.rejects(action(context)(), /cancelled/);
63+
assert.equal(started, false);
64+
assert.equal(context.pendingAccountDeviceId, 'desktop');
65+
});

0 commit comments

Comments
 (0)