Skip to content

feat: 手表免密加入账号(桌面端签发 + 手机端确认转交) - #2193

Merged
wgqqqqq merged 2 commits into
GCWing:mainfrom
wgqqqqq:feat/watch-provisioning
Aug 10, 2026
Merged

feat: 手表免密加入账号(桌面端签发 + 手机端确认转交)#2193
wgqqqqq merged 2 commits into
GCWing:mainfrom
wgqqqqq:feat/watch-provisioning

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

手表没有键盘,无法像其他端一样登录 BitFun 账号。这个 PR 打通「桌面端签发凭证 → 手机端确认并转交 → 手表直接可用」这条链路,手表端不出现任何输入框。

桌面端(Rust)

新增 provision_peer_device 命令:已配对的客户端通过已加密的 room channel 请求桌面端把手表注册到账号上,桌面端调用 relay 的 /api/auth/provision-device 后把签发的凭证原路返回。

  • 为什么不复用 GetDelegatedIdentity:那条给的是 24 小时委托令牌,适合手机——它自己能重新登录。手表是主设备且无法再输密码,需要 30 天完整凭证。
  • request_id 由被授权的设备生成,不是桌面端生成。手表 → 手机 → 桌面端这条链路上任意一环重试,重放的都是同一个幂等 relay 请求,不会在账号里多注册一台设备。
  • relay 看不到 master_key:它走 room channel 自己的加密,relay 只做转发。
  • 签发方持有与委托同一把账号租约,并在网络调用前后各校验一次账号代次——中途切账号会让请求失败,而不是拿一个已经不存在的账号签出凭证。

手机端(HarmonyOS)

手机是链路里唯一既能通过扫码 room channel 够到桌面端、又能把请求展示给人看的设备。

  • 确认卡片盖在所有路由之上,不放进设置页:手表正在等这个答复,把它埋在手机当前恰好停留的页面后面,会把一次 5 秒的确认变成一个客服问题。
  • provisionPeerDevice 直连 room client 而非走 send():account-device 通道到的是桌面端另一个处理器,那里没有可信配对身份,只会回 "not available on this host"。hasRoomChannel() 让调用方在提供功能之前就知道这件事,而不是等 45 秒超时。
  • 结果是「报告」不是「抛异常」:「桌面端答复了并且拒绝」带着一条值得原样展示的理由,而「根本没有答复」同时覆盖了桌面端离线和桌面端版本过旧两种情况(旧版本能解密载荷,但反序列化不认识的 variant 会失败,于是什么都不回)。Error 没法把这个区别带过传输层的错误收敛。
  • master_key 不以明文进入分布式 KV:手表随请求发布一次性 X25519 公钥,手机把密钥包给它,KV 里只有那块手表能解开的密文。

⚠️ Bundle 改名(合并前需要处理)

com.example.bitfun_mobilecom.bitfun.app,与手表端共用。

distributedKVStore 按 bundleName + storeId 隔离,两个不同 bundle 拿到的是两个互不相干的库,凭证交接物理上跑不通——所以这不是清理脚手架命名,是功能前提。

代价:已安装的旧包等于另一个 app,数据不通、用户需要重新登录;HAP 签名 profile 需要按新 bundle 重新生成后才能发版。

验证

  • cargo test -p bitfun-core provisioning7 passed, 0 failed:覆盖「必须先有可信配对」「绑定配对时确立的那个账号」「拒绝别的账号的签发方」「拒绝签给另一台设备的凭证」「拒绝 relay 会拒的 device_id 形状」「原样透出 relay 的失败原因」「响应释放前一直持有账号租约」。
  • cargo check -p bitfun-core / -p bitfun-services-integrations 均干净。
  • 已 rebase 到 upstream/main(e63084bc5),无冲突,rebase 后重跑测试结果一致。
  • 手机端为 ArkTS,本仓库无对应自动化测试;端到端联调(真机手机 + 手表)尚未进行。

wgqqqqq and others added 2 commits August 10, 2026 19:12
A watch has no keyboard, so it cannot log into a BitFun account the way
every other surface does. Add `provision_peer_device`: a paired client
asks the desktop over the already-encrypted room channel to register the
watch on the account, and the desktop answers with a full credential.

Why not reuse `GetDelegatedIdentity`: that yields a 24-hour delegated
token, which suits a phone that can re-authenticate on its own. The watch
is a primary surface with no way to re-enter a password, so it needs the
30-day full credential the relay's `/api/auth/provision-device` mints.

`request_id` is minted by the device being provisioned rather than by the
desktop, so a retry anywhere along the watch -> phone -> desktop chain
replays one idempotent relay request instead of registering a second
device on the account.

The relay never sees `master_key`: it travels back inside the room
channel's own encryption, which the relay only forwards.

The provisioner holds the same account lease as delegation and re-checks
the account generation on both sides of the network call, so an account
switch mid-flight fails the request instead of minting a credential
against the account that just went away.

Covered by 7 tests in `service::remote_connect::tests`: provisioning
requires a trusted pairing, is bound to the account established during
that pairing, rejects a provider for another account, rejects a
credential minted for a different device, rejects device ids the relay
would refuse, surfaces the relay's own failure reason, and holds the
account lease until the response is released.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone is the only device in the chain that can both reach the desktop
over a QR-paired room channel and show a human the request. It relays a
watch's provisioning request to the desktop, asks for approval, and hands
the minted credential to the watch over distributed KV.

The approval card sits above every route rather than inside a settings
screen: a watch is waiting on the answer, and burying it behind whatever
screen the phone happens to be on turns a five-second confirmation into a
support question.

`provisionPeerDevice` goes straight to the room client instead of through
`send()`. The account-device transport reaches a different desktop
handler that has no trusted pairing identity and would answer "not
available on this host"; `hasRoomChannel()` lets the caller find that out
before offering the feature rather than after a 45-second wait.

The outcome is reported, not thrown: "the desktop answered and refused"
carries a message worth showing verbatim, while "no answer at all" covers
both an offline desktop and one too old to know the command — an older
build decrypts the payload, fails to deserialize the unknown variant, and
replies with nothing. An `Error` cannot carry that distinction back
through the transport's error collapsing.

`master_key` is never written to the distributed KV in the clear. The
watch publishes an ephemeral X25519 public key with its request, and the
phone wraps the key to it, so the KV holds ciphertext only the requesting
watch can open.

Bundle name changes from the scaffold's `com.example.bitfun_mobile` to
`com.bitfun.app`, shared with the watch. `distributedKVStore` isolates
stores by bundleName + storeId, so two different bundles get two
unrelated databases and the handoff cannot work at all. The cost is that
an existing install becomes a different app: no data carries over and
users re-login. The HAP signing profile has to be regenerated for the new
bundle before this ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wgqqqqq
wgqqqqq merged commit 3dfc372 into GCWing:main Aug 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant