Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', '账号验证失败,请检查密码或网络后重试。'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
protected async startWatchProvisioning(): Promise<void> {
if (!this.settingsController.hasCloudAccountSession() && !this.hasRemoteBindingForResume()) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
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<void>;
Expand Down Expand Up @@ -894,6 +895,9 @@ export abstract class AppRootRuntimeComposition {
cloudCancelLogin: (): void => this.settingsController.cancelCloudLogin(),
cloudLogin: async (): Promise<string> => {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,27 +370,34 @@ export class CloudAccountClient {
session: CloudAccountSession,
deviceId: string,
deviceName: string,
requestId: string
requestId: string,
deviceSecret?: Uint8Array
): Promise<CloudProvisionedDevice> {
const keys = X25519.generateKeyPair();
const body: ProvisionDeviceRequest = {
public_key: Encoding.bytesToBase64(keys.publicKey),
device_id: deviceId,
device_name: deviceName,
device_kind: DEVICE_KIND_WATCH,
request_id: requestId
};
const wire = await this.request<ProvisionDeviceWire>(
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.');
const privateKey = deviceSecret ? Encoding.copyBytes(deviceSecret) : X25519.generateKeyPair().privateKey;
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<ProvisionDeviceWire>(
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: keys.privateKey };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PersistedWatchProvisionIdentity | undefined> {
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<void> {
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<void> {
try {
const store = this.requireStore();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
WatchProvisionRequest
} from './WatchProvisionProtocol';
import { WatchProvisionDisplay } from './WatchProvisionDisplay';
import { CloudAccountRequestError } from './CloudAccountClient';

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

Expand Down Expand Up @@ -200,6 +201,10 @@ export class WatchProvisionController {
}

private async runProvisioning(request: WatchProvisionRequest, password: string): Promise<boolean> {
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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading