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 @@ -611,12 +611,16 @@ export const EN_US_MESSAGES: [string, string][] = [
['watchProvision.approve', 'Allow'],
['watchProvision.reject', 'Deny'],
['watchProvision.gotIt', 'Got it'],
['watchProvision.working', 'Requesting authorization from the desktop...'],
['watchProvision.working', 'Setting up the watch account...'],
['watchProvision.passwordBody', 'Confirm your account password once more to create a separate sign-in for this watch. The password is used only for this verification.'],
['watchProvision.passwordPlaceholder', 'Account password'],
['watchProvision.passwordConfirm', 'Confirm'],
['watchProvision.doneBody', '{0} was added to the account and can be used on the watch now.'],
['watchProvision.rejected', 'Denied on the phone.'],
['watchProvision.busy', 'The phone is handling another device request. Try again later.'],
['watchProvision.errors.noDesktop', 'Scan and connect a desktop on the phone before authorizing a watch.'],
['watchProvision.errors.desktopUnreachable', 'The desktop is offline or too old. Update it and retry.'],
['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'],
['watchProvision.errors.handoffFailed', 'Authorization finished, but the credential could not be sent to the watch. Retry on the watch.'],

['ui.emptyMessage', '(empty message)'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,12 +611,16 @@ export const ZH_CN_MESSAGES: [string, string][] = [
['watchProvision.approve', '允许'],
['watchProvision.reject', '拒绝'],
['watchProvision.gotIt', '知道了'],
['watchProvision.working', '正在从桌面端申请授权...'],
['watchProvision.working', '正在为手表开通账号...'],
['watchProvision.passwordBody', '需要再确认一次账号密码,才能给这块手表创建独立的登录凭证。密码只会用于本次验证。'],
['watchProvision.passwordPlaceholder', '账号密码'],
['watchProvision.passwordConfirm', '确认'],
['watchProvision.doneBody', '{0} 已加入账号,手表上可以直接使用了。'],
['watchProvision.rejected', '已在手机上拒绝。'],
['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'],
['watchProvision.errors.noDesktop', '需要先在手机上扫码连接桌面端,才能给手表授权。'],
['watchProvision.errors.desktopUnreachable', '桌面端未在线或版本过旧,请更新桌面端后重试。'],
['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'],
['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'],

['ui.emptyMessage', '(空消息)'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ struct AppRoot {
// not be hidden behind whatever screen the phone happens to be on.
WatchProvisionCard({
state: this.runtime.watchProvisionState,
onApprove: () => {
void this.runtime.watchProvisionController.approve();
onApprove: (password: string) => {
void this.runtime.watchProvisionController.approve(password);
},
onReject: () => {
void this.runtime.watchProvisionController.reject();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { MobileDesignTypography } from '../../generated/MobileDesignTokens';
import { RemoteI18n } from '../../i18n/RemoteI18n';
import { WatchProvisionPhase, WatchProvisionState } from '../state/WatchProvisionState';
import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme';
import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT,
SUBTLE } from './Theme';

/**
* The one place a watch can be added to the account. It is deliberately modal:
Expand All @@ -11,9 +12,10 @@ import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACT
@ComponentV2
export struct WatchProvisionCard {
@Param state: WatchProvisionState = new WatchProvisionState();
@Event onApprove: () => void = () => {};
@Event onApprove: (password: string) => void = (_password: string) => {};
@Event onReject: () => void = () => {};
@Event onDismiss: () => void = () => {};
@Local password: string = '';

build() {
if (this.state.visible()) {
Expand Down Expand Up @@ -61,6 +63,36 @@ export struct WatchProvisionCard {
.lineHeight(MobileDesignTypography.bodyLarge.lineHeight)
.fontColor(MUTED)
.width('100%')
} else if (this.state.phase === WatchProvisionPhase.Password) {
Column({ space: 12 }) {
Text(RemoteI18n.t('watchProvision.passwordBody'))
.fontSize(MobileDesignTypography.bodyLarge.size)
.lineHeight(MobileDesignTypography.bodyLarge.lineHeight)
.fontColor(MUTED)
.width('100%')
TextInput({
placeholder: RemoteI18n.t('watchProvision.passwordPlaceholder'),
text: this.password
})
.height(54)
.fontSize(MobileDesignTypography.bodyLarge.size)
.fontColor(INK)
.placeholderColor(SUBTLE)
.backgroundColor(SOFT)
.borderRadius(18)
.padding({ left: 18, right: 18 })
.type(InputType.Password)
.showPasswordIcon(true)
.onChange((value: string) => { this.password = value; })
if (this.state.message.length > 0) {
Text(this.state.message)
.fontSize(MobileDesignTypography.bodySmall.size)
.lineHeight(MobileDesignTypography.bodySmall.lineHeight)
.fontColor(RED)
.width('100%')
}
}
.width('100%')
} else if (this.state.phase === WatchProvisionPhase.Working) {
Row({ space: 10 }) {
LoadingProgress()
Expand Down Expand Up @@ -101,7 +133,38 @@ export struct WatchProvisionCard {
.fontColor(PRIMARY_ACTION_TEXT)
.backgroundColor(PRIMARY_ACTION)
.borderRadius(28)
.onClick(this.onApprove)
.onClick(() => { this.onApprove(''); })
}
.width('100%')
} else if (this.state.phase === WatchProvisionPhase.Password) {
Row({ space: 12 }) {
Button(RemoteI18n.t('watchProvision.reject'))
.layoutWeight(1)
.height(56)
.fontSize(MobileDesignTypography.labelLarge.size)
.fontWeight(FontWeight.Bold)
.fontColor(INK)
.backgroundColor(SOFT)
.borderRadius(28)
.onClick(() => {
this.password = '';
this.onReject();
})
Button(RemoteI18n.t('watchProvision.passwordConfirm'))
.layoutWeight(1)
.height(56)
.fontSize(MobileDesignTypography.labelLarge.size)
.fontWeight(FontWeight.Bold)
.fontColor(PRIMARY_ACTION_TEXT)
.backgroundColor(PRIMARY_ACTION)
.borderRadius(28)
.enabled(this.password.length > 0)
.opacity(this.password.length > 0 ? 1 : 0.45)
.onClick(() => {
const password = this.password;
this.password = '';
this.onApprove(password);
})
}
.width('100%')
} else if (this.state.phase !== WatchProvisionPhase.Working) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ export abstract class AppRootRuntimeComposition {
// account of its own to mint from.
canProvision: (): boolean => this.settingsController.canMintWatchCredential() ||
this.canProvisionViaDesktop(),
provision: (deviceId: string, deviceName: string, requestId: string): Promise<WatchProvisionOutcome> =>
this.provisionWatchDevice(deviceId, deviceName, requestId)
provision: (deviceId: string, deviceName: string, requestId: string, password: string): Promise<WatchProvisionOutcome> =>
this.provisionWatchDevice(deviceId, deviceName, requestId, password)
};
readonly watchProvisionController: WatchProvisionController =
new WatchProvisionController(this.watchProvisionState, this.watchProvisionPort);
Expand All @@ -208,9 +208,10 @@ export abstract class AppRootRuntimeComposition {
private async provisionWatchDevice(
deviceId: string,
deviceName: string,
requestId: string
requestId: string,
password: string
): Promise<WatchProvisionOutcome> {
const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId);
const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId, password);
if (minted) {
return minted;
}
Expand All @@ -220,6 +221,7 @@ export abstract class AppRootRuntimeComposition {
const outcome = await this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId);
return {
ok: outcome.ok,
passwordRequired: false,
// The desktop mints against the relay its room lives on, which is not
// necessarily the one this phone's account is on.
relayUrl: outcome.ok ? this.sessionManager.roomRelayEndpoint() : '',
Expand All @@ -234,7 +236,7 @@ export abstract class AppRootRuntimeComposition {

private static provisionUnavailable(): WatchProvisionOutcome {
return {
ok: false, relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '',
ok: false, passwordRequired: false, relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '',
failure: '', desktopReported: false
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export enum WatchProvisionPhase {
Hidden = 'hidden',
/** A request arrived and its owner has not answered yet. */
Asking = 'asking',
/** Approved; the desktop is minting the credential. */
/** The relay needs a normal account login to add this watch. */
Password = 'password',
/** Approved; the phone is obtaining and handing off the credential. */
Working = 'working',
Done = 'done',
Failed = 'failed'
Expand All @@ -31,6 +33,11 @@ export class WatchProvisionState implements WatchProvisionDisplay {
this.message = '';
}

requirePassword(message: string): void {
this.phase = WatchProvisionPhase.Password;
this.message = message;
}

done(message: string): void {
this.phase = WatchProvisionPhase.Done;
this.message = message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,41 @@ export class SettingsController {
async provisionWatchCredential(
deviceId: string,
deviceName: string,
requestId: string
requestId: string,
password: string = ''
): Promise<WatchProvisionOutcome | undefined> {
const session = this.cloudSession;
if (!session || this.cloudRelayUrl.length === 0) {
return undefined;
}
const cloud = this.requireCloud();
try {
if (password.length > 0) {
const username = cloud.remoteState.accountUsername.trim();
if (username.length === 0) {
throw new Error('The signed-in account name is unavailable.');
}
const provisioned = await cloud.client.loginWatch(
this.cloudRelayUrl, username, password, session, deviceId, deviceName, requestId);
RemoteLogger.info(`watch credential obtained through account login device=${provisioned.deviceId}`);
return {
ok: true,
passwordRequired: false,
relayUrl: this.cloudRelayUrl,
token: provisioned.token,
userId: provisioned.userId,
masterKeyBase64: Encoding.bytesToBase64(session.masterKey),
deviceId: provisioned.deviceId,
failure: '',
desktopReported: false
};
}
const provisioned = await cloud.client.provisionDevice(
this.cloudRelayUrl, session, deviceId, deviceName, requestId);
RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`);
return {
ok: true,
passwordRequired: false,
relayUrl: this.cloudRelayUrl,
token: provisioned.token,
userId: provisioned.userId,
Expand All @@ -228,10 +250,26 @@ export class SettingsController {
desktopReported: false
};
} catch (err) {
// Once the user is confirming the account, every failure belongs to the
// login attempt itself. Do not reinterpret a rejected password or an old
// relay response as a cue to ask the desktop.
if (password.length > 0) {
throw err instanceof Error ? err : new Error('Watch account verification failed.');
}
if (err instanceof CloudAccountRequestError && (err.statusCode === 401 || err.statusCode === 403)) {
RemoteLogger.info('phone account may not mint a device credential; deferring to the desktop');
return undefined;
}
if (err instanceof CloudAccountRequestError && (err.statusCode === 404 || err.statusCode >= 500) &&
cloud.remoteState.accountUsername.trim().length > 0) {
RemoteLogger.info('relay device provisioning unavailable; requesting account confirmation');
return {
ok: false,
passwordRequired: true,
relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', failure: '',
desktopReported: false
};
}
throw err instanceof Error ? err : new Error('Watch credential provisioning failed.');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface LoginRequest {
device_id: string;
device_name: string;
device_kind: string;
request_id?: string;
}
interface RelayErrorResponse { error?: string; }

Expand Down Expand Up @@ -146,10 +147,49 @@ export class CloudAccountClient {
private readonly cipher: HarmonyRemoteCryptoCipher = new HarmonyRemoteCryptoCipher();

async login(relayUrl: string, username: string, password: string, deviceId: string): Promise<CloudAccountSession> {
return this.loginDevice(
relayUrl, username, password, deviceId, 'HarmonyOS Phone', DEVICE_KIND_MOBILE);
}

/**
* Compatibility path for relays whose dedicated device-provisioning route is
* present but cannot mint against their existing account database.
*
* This is still a real device login: the watch receives its own token and
* device row. Only the password proof travels to the relay, while the
* plaintext password and the account master key stay on this phone.
*/
async loginWatch(
relayUrl: string,
username: string,
password: string,
currentSession: CloudAccountSession,
deviceId: string,
deviceName: string,
requestId: string
): Promise<CloudProvisionedDevice> {
const watchSession = await this.loginDevice(
relayUrl, username, password, deviceId, deviceName, DEVICE_KIND_WATCH, requestId);
if (watchSession.userId !== currentSession.userId ||
!CloudAccountClient.sameBytes(watchSession.masterKey, currentSession.masterKey)) {
throw new Error('The confirmed account does not match the signed-in account.');
}
return { token: watchSession.token, userId: watchSession.userId, deviceId };
}

private async loginDevice(
relayUrl: string,
username: string,
password: string,
deviceId: string,
deviceName: string,
deviceKind: string,
requestId: string = ''
): Promise<CloudAccountSession> {
const normalizedRelayUrl = relayUrl.trim() || DEFAULT_CLOUD_RELAY_URL;
const normalizedUser = username.trim();
const startedAt = Date.now();
RemoteLogger.info(`cloud login start relay=${normalizedRelayUrl}`);
RemoteLogger.info(`cloud device login start relay=${normalizedRelayUrl} kind=${deviceKind}`);
if (normalizedUser.length === 0 || normalizedUser.length > 128 || password.length === 0 || password.length > 1024) {
throw new Error('Invalid account credentials.');
}
Expand All @@ -169,14 +209,28 @@ export class CloudAccountClient {
username: normalizedUser,
password_hash: Encoding.bytesToBase64(passwordHash),
device_id: deviceId,
device_name: 'HarmonyOS Phone',
device_kind: DEVICE_KIND_MOBILE
device_name: deviceName,
device_kind: deviceKind
};
if (requestId.length > 0) {
loginRequest.request_id = requestId;
}
const auth = await this.post<AccountAuthResponse>(normalizedRelayUrl, '/api/auth/login', loginRequest);
RemoteLogger.info(`cloud login authenticated elapsed_ms=${Date.now() - startedAt}`);
return { token: auth.token, userId: auth.user_id, masterKey };
}

private static sameBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) {
return false;
}
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference |= left[index] ^ right[index];
}
return difference === 0;
}

/**
* Adds another device to this account and returns its own credential.
*
Expand Down
Loading