From 1731bb4e552b0735acd8f4b4680ef5509e9b82c2 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 8 Oct 2026 14:54:01 +0800 Subject: [PATCH 1/2] fix(harmonyos): recover watch provisioning after interrupted login --- .../entry/src/main/ets/i18n/EnUsMessages.ets | 2 + .../entry/src/main/ets/i18n/ZhCnMessages.ets | 2 + .../main/ets/pages/runtime/AppRootRuntime.ets | 2 +- .../runtime/AppRootRuntimeComposition.ets | 4 + .../pages/viewmodel/SettingsController.ets | 19 ++- .../main/ets/services/CloudAccountClient.ets | 10 +- .../ets/services/CloudAccountSessionStore.ets | 30 +++++ .../ets/services/WatchProvisionController.ets | 9 ++ .../ets/services/WatchProvisionProtocol.ets | 2 +- .../tests/watch-login-lifecycle.test.cjs | 65 ++++++++++ .../tests/watch-provision-recovery.test.cjs | 112 ++++++++++++++++++ 11 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 src/apps/mobile/harmonyos/tools/tests/watch-login-lifecycle.test.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 1e73b9aca8..94b100d4da 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -683,6 +683,8 @@ export const EN_US_MESSAGES: [string, string][] = [ ['watchProvision.busy', 'The phone is handling another device request. Try again later.'], ['watchProvision.errors.noDesktop', 'Sign in with email or GitHub on the phone, or connect a desktop signed in to the same account.'], ['watchProvision.errors.accountUnavailable', 'The account could not be verified. Check the phone network and try again.'], + ['watchProvision.errors.requestExpired', 'The watch sign-in request expired. Start sign-in again on the watch.'], + ['watchProvision.errors.alreadyRegistered', 'This watch is registered, but this phone has no recoverable credential. Revoke the old watch authorization and retry.'], ['watchProvision.errors.desktopUnreachable', 'The desktop could not be reached. Make sure it is online and try again.'], ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same OpenBitFun account.'], ['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index a08b5bcf57..e3bf40e598 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -683,6 +683,8 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'], ['watchProvision.errors.noDesktop', '请先在手机上使用邮箱或 GitHub 登录,或连接已登录同一账号的桌面端。'], ['watchProvision.errors.accountUnavailable', '暂时无法验证账号,请检查手机网络后重试。'], + ['watchProvision.errors.requestExpired', '手表登录请求已过期,请在手表上重新发起登录。'], + ['watchProvision.errors.alreadyRegistered', '这块手表已注册,但本机没有可恢复的授权凭据。请撤销该手表的旧授权后重试。'], ['watchProvision.errors.desktopUnreachable', '暂时无法连接桌面端,请确认桌面端在线后重试。'], ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 OpenBitFun 账号。'], ['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 3c2a4cac53..0ae02575d5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -77,7 +77,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { * mints the watch's credential itself in that case, so waiting for a live * connection would keep the listener down exactly when it is not needed. */ - private async startWatchProvisioning(): Promise { + protected async startWatchProvisioning(): Promise { if (!this.settingsController.hasCloudAccountSession() && !this.hasRemoteBindingForResume()) { return; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 8ca635dc57..23f661bbb3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -121,6 +121,7 @@ export abstract class AppRootRuntimeComposition { abstract failRemoteConnection(err: Object): void; abstract handleNavigationBack(route: AppRoute): boolean; abstract hasRemoteBindingForResume(): boolean; + protected abstract startWatchProvisioning(): Promise; abstract isRemoteConversationContext(sessionId: string): boolean; abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; abstract loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise; @@ -894,6 +895,9 @@ export abstract class AppRootRuntimeComposition { cloudCancelLogin: (): void => this.settingsController.cancelCloudLogin(), cloudLogin: async (): Promise => { const user = await this.settingsController.loginCloudAccount(); + // Returning from authorization can show the page before the account + // session is saved. Start listening after login completes as well. + await this.startWatchProvisioning(); const pending = this.pendingAccountDeviceId; this.pendingAccountDeviceId = ''; if (pending.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index e51c29f27d..ea1b1e6dae 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -135,23 +135,38 @@ export class SettingsController { return undefined; } const cloud = this.requireCloud(); + // Persist the identity before registration. A timed-out or cancelled + // handoff can then replay registration without rotating the watch's key. + let identity = await cloud.sessionStore.loadWatchProvisionIdentity(this.cloudRelayUrl, session.userId, deviceId); + if (!identity) { + identity = { + relayUrl: this.cloudRelayUrl, userId: session.userId, deviceId, requestId, + privateKeyBase64: Encoding.bytesToBase64(Encoding.randomBytes(32)) + }; + await cloud.sessionStore.saveWatchProvisionIdentity(identity); + } + const secret = Encoding.base64ToBytes(identity.privateKeyBase64); try { const provisioned = await cloud.client.provisionDevice( - this.cloudRelayUrl, session, deviceId, deviceName, requestId); + this.cloudRelayUrl, session, deviceId, deviceName, identity.requestId, secret); RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`); + const masterKeyBase64 = Encoding.bytesToBase64(provisioned.deviceSecret); + provisioned.deviceSecret.fill(0); return { ok: true, passwordRequired: false, relayUrl: this.cloudRelayUrl, token: provisioned.token, userId: provisioned.userId, - masterKeyBase64: Encoding.bytesToBase64(provisioned.deviceSecret), + masterKeyBase64, deviceId: provisioned.deviceId, failure: '', desktopReported: false }; } catch (err) { throw err instanceof Error ? err : new Error('Watch credential provisioning failed.'); + } finally { + secret.fill(0); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index d19108756c..048cd1ba23 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -370,11 +370,13 @@ export class CloudAccountClient { session: CloudAccountSession, deviceId: string, deviceName: string, - requestId: string + requestId: string, + deviceSecret?: Uint8Array ): Promise { - const keys = X25519.generateKeyPair(); + const privateKey = deviceSecret ? Encoding.copyBytes(deviceSecret) : X25519.generateKeyPair().privateKey; + if (privateKey.length !== 32) throw new Error('Invalid watch device identity.'); const body: ProvisionDeviceRequest = { - public_key: Encoding.bytesToBase64(keys.publicKey), + public_key: Encoding.bytesToBase64(X25519.scalarMultBase(privateKey)), device_id: deviceId, device_name: deviceName, device_kind: DEVICE_KIND_WATCH, @@ -390,7 +392,7 @@ export class CloudAccountClient { // in as somebody else. Refuse rather than pass it on. throw new Error('Relay returned a mismatched provisioned device identity.'); } - return { token, userId, deviceId: provisionedDeviceId, deviceSecret: keys.privateKey }; + return { token, userId, deviceId: provisionedDeviceId, deviceSecret: privateKey }; } /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets index fa6758c704..98e8cfaef5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets @@ -21,6 +21,14 @@ export interface PersistedCloudAccountSession { targetDeviceName?: string; } +export interface PersistedWatchProvisionIdentity { + relayUrl: string; + userId: string; + deviceId: string; + requestId: string; + privateKeyBase64: string; +} + /** Persists only an HUKS-encrypted account session, never plaintext secrets. */ export class CloudAccountSessionStore { private store?: preferences.Preferences; @@ -90,6 +98,28 @@ export class CloudAccountSessionStore { await this.writeSealed(CIPHER_KEY, IV_KEY, JSON.stringify(session)); } + async loadWatchProvisionIdentity(relayUrl: string, userId: string, + deviceId: string): Promise { + const key = this.watchProvisionKey(relayUrl, userId, deviceId); + const text = await this.readSealed(`${key}_cipher`, `${key}_iv`); + if (!text) return undefined; + const identity = JSON.parse(text) as PersistedWatchProvisionIdentity; + if (identity.relayUrl !== relayUrl || identity.userId !== userId || identity.deviceId !== deviceId || + !identity.requestId || Encoding.base64ToBytes(identity.privateKeyBase64).length !== 32) { + throw new Error('Stored watch provisioning identity is invalid.'); + } + return identity; + } + + async saveWatchProvisionIdentity(identity: PersistedWatchProvisionIdentity): Promise { + const key = this.watchProvisionKey(identity.relayUrl, identity.userId, identity.deviceId); + await this.writeSealed(`${key}_cipher`, `${key}_iv`, JSON.stringify(identity)); + } + + private watchProvisionKey(relayUrl: string, userId: string, deviceId: string): string { + return `watch_provision_v1_${encodeURIComponent(relayUrl)}_${encodeURIComponent(userId)}_${deviceId}`; + } + private async writeSealed(cipherKey: string, ivKey: string, plaintext: string): Promise { try { const store = this.requireStore(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets index 32cdf163f5..2f847f0e78 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets @@ -9,6 +9,7 @@ import { WatchProvisionRequest } from './WatchProvisionProtocol'; import { WatchProvisionDisplay } from './WatchProvisionDisplay'; +import { CloudAccountRequestError } from './CloudAccountClient'; const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC'; @@ -200,6 +201,10 @@ export class WatchProvisionController { } private async runProvisioning(request: WatchProvisionRequest, password: string): Promise { + if (WatchProvisionProtocol.isExpired(request, Date.now())) { + await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.requestExpired')); + return true; + } if (!this.port.canProvision()) { await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.noDesktop')); return true; @@ -209,6 +214,10 @@ export class WatchProvisionController { outcome = await this.port.provision(request.deviceId, request.deviceName, request.requestId, password); } catch (err) { RemoteLogger.error(`watch provisioning failed: ${WatchProvisionController.errorText(err)}`); + if (err instanceof CloudAccountRequestError && err.statusCode === 409) { + await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.alreadyRegistered')); + return true; + } if (password.length > 0) { this.state.requirePassword(RemoteI18n.t('watchProvision.errors.passwordFailed')); return false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets index ef3b8a9b7b..f5fb84fbbe 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets @@ -25,7 +25,7 @@ export const WATCH_PROVISION_PROTOCOL_VERSION: number = 1; * watch never got to clear would re-open the confirmation card on every phone * launch, long after whoever asked for it walked away. */ -export const WATCH_PROVISION_REQUEST_TTL_MS: number = 5 * 60 * 1000; +export const WATCH_PROVISION_REQUEST_TTL_MS: number = 3 * 60 * 1000; export const WATCH_PROVISION_STATUS_OK: string = 'ok'; export const WATCH_PROVISION_STATUS_ERROR: string = 'error'; diff --git a/src/apps/mobile/harmonyos/tools/tests/watch-login-lifecycle.test.cjs b/src/apps/mobile/harmonyos/tools/tests/watch-login-lifecycle.test.cjs new file mode 100644 index 0000000000..a567a51cb0 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/watch-login-lifecycle.test.cjs @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// Exercise the actual shared login action without creating native UI owners. +const file = path.join(__dirname, '../../entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets'); +const source = fs.readFileSync(file, 'utf8'); +const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); +let initializer; +function visit(node) { + if (ts.isPropertyAssignment(node) && node.name.getText(ast) === 'cloudLogin') { + initializer = node.initializer.getText(ast); + } + ts.forEachChild(node, visit); +} +visit(ast); +assert.ok(initializer, 'shared login action exists'); +const js = ts.transpileModule(`const action = ${initializer};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; +function action(context) { + return new Function(`${js}\nreturn action;`).call(context); +} + +test('foreground restoration before login resolves still starts the watch listener after login', async () => { + let resolveLogin; + const calls = []; + const context = { + settingsController: { loginCloudAccount: () => new Promise(resolve => { resolveLogin = resolve; }) }, + startWatchProvisioning: async () => { calls.push('listen'); }, + pendingAccountDeviceId: '', + }; + const result = action(context)(); + assert.deepEqual(calls, []); + resolveLogin('account'); + assert.equal(await result, 'account'); + assert.deepEqual(calls, ['listen']); +}); + +test('watch listener starts before resuming a pending desktop link', async () => { + const calls = []; + const context = { + settingsController: { loginCloudAccount: async () => 'account' }, + startWatchProvisioning: async () => { calls.push('listen'); }, + pendingAccountDeviceId: 'desktop', + connectAccountDeviceLink: async id => { calls.push(id); }, + }; + assert.equal(await action(context)(), 'account'); + assert.deepEqual(calls, ['listen', 'desktop']); + assert.equal(context.pendingAccountDeviceId, ''); +}); + +test('cancelled or failed account login does not start provisioning or consume a pending link', async () => { + let started = false; + const context = { + settingsController: { loginCloudAccount: async () => { throw new Error('cancelled'); } }, + startWatchProvisioning: async () => { started = true; }, + pendingAccountDeviceId: 'desktop', + }; + await assert.rejects(action(context)(), /cancelled/); + assert.equal(started, false); + assert.equal(context.pendingAccountDeviceId, 'desktop'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs b/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs new file mode 100644 index 0000000000..07ad5f9642 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs @@ -0,0 +1,112 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const load = (file, mocks = {}) => { + const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/', file), 'utf8'); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + const exports = {}; + new Function('require', 'exports', js)(name => mocks[name] || {}, exports); + return exports; +}; +const Encoding = { + randomBytes: size => new Uint8Array(size).fill(7), + bytesToBase64: bytes => Buffer.from(bytes).toString('base64'), + base64ToBytes: text => new Uint8Array(Buffer.from(text, 'base64')), +}; +const log = { info() {}, warn() {}, error() {} }; +const { SettingsController } = load('pages/viewmodel/SettingsController.ets', { + '../../services/Encoding': { Encoding }, + '../../services/RemoteLogger': { RemoteLogger: log }, +}); +function fixture(failFirst = false, failSave = false) { + const events = []; + const records = new Map(); + const calls = []; + const store = { + loadWatchProvisionIdentity: async (relay, user, device) => records.get(`${relay}/${user}/${device}`), + saveWatchProvisionIdentity: async identity => { + events.push('save'); + if (failSave) throw new Error('storage failed'); + records.set(`${identity.relayUrl}/${identity.userId}/${identity.deviceId}`, { ...identity }); + }, + }; + const client = { provisionDevice: async (relay, session, device, name, request, secret) => { + events.push('http'); + calls.push({ relay, user: session.userId, device, request, secret: Encoding.bytesToBase64(secret), buffer: secret }); + if (failFirst && calls.length === 1) throw new Error('response lost'); + return { token: 'watch-token', userId: session.userId, deviceId: device, deviceSecret: new Uint8Array(secret) }; + } }; + const create = (user = 'account', relay = 'https://relay') => { + const controller = new SettingsController({ sessionStore: store, client }); + controller.cloudSession = { token: 'phone-token', userId: user }; + controller.cloudRelayUrl = relay; + return controller; + }; + return { events, calls, create }; +} + +test('identity is persisted before HTTP; a lost response replays the same key and id after restart', async () => { + const f = fixture(true); + await assert.rejects(f.create().provisionWatchCredential('watch', 'Watch', 'first-request'), /response lost/); + const result = await f.create().provisionWatchCredential('watch', 'Watch', 'new-request'); + assert.deepEqual(f.events, ['save', 'http', 'http']); + assert.equal(f.calls[0].request, f.calls[1].request); + assert.equal(f.calls[0].secret, f.calls[1].secret); + assert.equal(result.token, 'watch-token'); + assert.ok(f.calls.every(call => call.buffer.every(byte => byte === 0))); +}); + +test('successful but undelivered handoff can be reauthorized without registering a new key', async () => { + const f = fixture(); + const first = await f.create().provisionWatchCredential('watch', 'Watch', 'first-request'); + const next = await f.create().provisionWatchCredential('watch', 'Watch', 'new-request'); + assert.equal(first.masterKeyBase64, next.masterKeyBase64); + assert.equal(f.calls[1].request, 'first-request'); +}); + +test('different accounts and relay endpoints do not reuse a provisioning request', async () => { + const f = fixture(); + await f.create('a').provisionWatchCredential('watch', 'Watch', 'a-request'); + await f.create('b').provisionWatchCredential('watch', 'Watch', 'b-request'); + await f.create('a', 'https://other').provisionWatchCredential('watch', 'Watch', 'other-request'); + assert.deepEqual(f.calls.map(call => call.request), ['a-request', 'b-request', 'other-request']); +}); + +test('secure storage failure prevents server registration', async () => { + const f = fixture(false, true); + await assert.rejects(f.create().provisionWatchCredential('watch', 'Watch', 'request'), /storage failed/); + assert.equal(f.calls.length, 0); +}); + +class RequestError extends Error { constructor(code) { super('registration conflict'); this.statusCode = code; } } +const { HarmonyUpgradeIdentityContract } = load('services/HarmonyUpgradeIdentityContract.ets'); +const { WatchProvisionProtocol, WATCH_PROVISION_REQUEST_TTL_MS } = load('services/WatchProvisionProtocol.ets', { './HarmonyUpgradeIdentityContract': { HarmonyUpgradeIdentityContract } }); +const { WatchProvisionController } = load('services/WatchProvisionController.ets', { + '../i18n/RemoteI18n': { RemoteI18n: { t: key => key } }, + './RemoteLogger': { RemoteLogger: log }, + './CloudAccountClient': { CloudAccountRequestError: RequestError }, + './WatchProvisionProtocol': { WatchProvisionProtocol }, +}); +function controllerFixture(provision) { + const display = { ask() {}, working() {}, fail(message) { this.message = message; } }; + const controller = new WatchProvisionController(display, { canProvision: () => true, provision }); + controller.pending = { requestId: 'request', deviceId: 'watch', deviceName: 'Watch', createdMs: Date.now() }; + return { controller, display }; +} + +test('a card that expires before approval cannot register a device', async () => { + let called = false; + const f = controllerFixture(async () => { called = true; }); + f.controller.pending.createdMs -= WATCH_PROVISION_REQUEST_TTL_MS + 1000; + await f.controller.approve(); + assert.equal(called, false); + assert.equal(f.display.message, 'watchProvision.errors.requestExpired'); +}); + +test('a legacy orphaned registration reports conflict rather than network failure', async () => { + const f = controllerFixture(async () => { throw new RequestError(409); }); + await f.controller.approve(); + assert.equal(f.display.message, 'watchProvision.errors.alreadyRegistered'); +}); From 33595b1ab679f118fb04e9bf1680df167333c433 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 8 Oct 2026 16:02:21 +0800 Subject: [PATCH 2/2] fix(harmonyos): preserve watch timeout contract and clear failed keys --- .../main/ets/services/CloudAccountClient.ets | 41 +++++++++------- .../ets/services/WatchProvisionProtocol.ets | 2 +- .../tests/watch-provision-recovery.test.cjs | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index 048cd1ba23..5a99e9dedc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -374,25 +374,30 @@ export class CloudAccountClient { deviceSecret?: Uint8Array ): Promise { const privateKey = deviceSecret ? Encoding.copyBytes(deviceSecret) : X25519.generateKeyPair().privateKey; - if (privateKey.length !== 32) throw new Error('Invalid watch device identity.'); - const body: ProvisionDeviceRequest = { - public_key: Encoding.bytesToBase64(X25519.scalarMultBase(privateKey)), - device_id: deviceId, - device_name: deviceName, - device_kind: DEVICE_KIND_WATCH, - request_id: requestId - }; - const wire = await this.request( - relayUrl, '/api/auth/provision-device', 'POST', body, session.token); - const token = (wire.token || '').trim(); - const userId = (wire.user_id || '').trim(); - const provisionedDeviceId = (wire.device_id || '').trim(); - if (token.length === 0 || userId !== session.userId || provisionedDeviceId !== deviceId) { - // A credential naming a different account or device would sign the watch - // in as somebody else. Refuse rather than pass it on. - throw new Error('Relay returned a mismatched provisioned device identity.'); + try { + if (privateKey.length !== 32) throw new Error('Invalid watch device identity.'); + const body: ProvisionDeviceRequest = { + public_key: Encoding.bytesToBase64(X25519.scalarMultBase(privateKey)), + device_id: deviceId, + device_name: deviceName, + device_kind: DEVICE_KIND_WATCH, + request_id: requestId + }; + const wire = await this.request( + relayUrl, '/api/auth/provision-device', 'POST', body, session.token); + const token = (wire.token || '').trim(); + const userId = (wire.user_id || '').trim(); + const provisionedDeviceId = (wire.device_id || '').trim(); + if (token.length === 0 || userId !== session.userId || provisionedDeviceId !== deviceId) { + // A credential naming a different account or device would sign the watch + // in as somebody else. Refuse rather than pass it on. + throw new Error('Relay returned a mismatched provisioned device identity.'); + } + return { token, userId, deviceId: provisionedDeviceId, deviceSecret: privateKey }; + } catch (err) { + privateKey.fill(0); + throw err instanceof Error ? err : new Error('Watch credential provisioning failed.'); } - return { token, userId, deviceId: provisionedDeviceId, deviceSecret: privateKey }; } /** diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets index f5fb84fbbe..ef3b8a9b7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionProtocol.ets @@ -25,7 +25,7 @@ export const WATCH_PROVISION_PROTOCOL_VERSION: number = 1; * watch never got to clear would re-open the confirmation card on every phone * launch, long after whoever asked for it walked away. */ -export const WATCH_PROVISION_REQUEST_TTL_MS: number = 3 * 60 * 1000; +export const WATCH_PROVISION_REQUEST_TTL_MS: number = 5 * 60 * 1000; export const WATCH_PROVISION_STATUS_OK: string = 'ok'; export const WATCH_PROVISION_STATUS_ERROR: string = 'error'; diff --git a/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs b/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs index 07ad5f9642..52160cd084 100644 --- a/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/watch-provision-recovery.test.cjs @@ -110,3 +110,50 @@ test('a legacy orphaned registration reports conflict rather than network failur await f.controller.approve(); assert.equal(f.display.message, 'watchProvision.errors.alreadyRegistered'); }); + +test('phone retains the existing five-minute lifetime beyond the watch three-minute wait', () => { + const now = Date.now(); + const request = { createdMs: now - 4 * 60 * 1000 }; + assert.equal(WatchProvisionProtocol.isExpired(request, now), false); + assert.equal(WatchProvisionProtocol.isExpired(request, now + 2 * 60 * 1000), true); +}); + +function accountClientFixture(response) { + let copiedKey; + const { CloudAccountClient } = load('services/CloudAccountClient.ets', { + './Encoding': { Encoding: { + ...Encoding, + copyBytes: bytes => { copiedKey = new Uint8Array(bytes); return copiedKey; }, + } }, + './X25519': { X25519: { scalarMultBase: () => new Uint8Array(32) } }, + './RemoteCrypto': { HarmonyRemoteCryptoCipher: class {} }, + }); + const client = new CloudAccountClient(); + client.request = response; + const original = new Uint8Array(32).fill(7); + const provision = () => client.provisionDevice('https://relay', { token: 'phone', userId: 'account' }, + 'watch', 'Watch', 'request', original); + return { provision, original, copied: () => copiedKey }; +} + +test('HTTP failure clears the owned copy without destroying the persisted identity input', async () => { + const failure = new Error('response lost'); + const f = accountClientFixture(async () => { throw failure; }); + await assert.rejects(f.provision(), err => err === failure); + assert.ok(f.copied().every(byte => byte === 0)); + assert.ok(f.original.every(byte => byte === 7)); +}); + +test('mismatched relay identity clears the copied private key', async () => { + const f = accountClientFixture(async () => ({ token: 'watch-token', user_id: 'other', device_id: 'watch' })); + await assert.rejects(f.provision(), /mismatched/); + assert.ok(f.copied().every(byte => byte === 0)); +}); + +test('successful registration transfers the copied private key to its caller', async () => { + const f = accountClientFixture(async () => ({ token: 'watch-token', user_id: 'account', device_id: 'watch' })); + const result = await f.provision(); + assert.equal(result.deviceSecret, f.copied()); + assert.ok(result.deviceSecret.every(byte => byte === 7)); + result.deviceSecret.fill(0); +});