Skip to content

Commit b917a6f

Browse files
committed
feat(totp): support origin-bound 2FA for bridge
1 parent 9da059f commit b917a6f

File tree

3 files changed

+69
-14
lines changed

3 files changed

+69
-14
lines changed

cli/src/commands/bridge.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ struct SuggestionItem {
125125
title: String,
126126
username_hint: Option<String>,
127127
match_strength: u8,
128+
credential_type: String,
128129
}
129130

130131
#[derive(Debug, Serialize)]
@@ -157,6 +158,9 @@ struct FillResponse {
157158
struct TotpPayload {
158159
origin: String,
159160
item_id: String,
161+
/// Indicates this request was triggered by an explicit user action (click, keyboard).
162+
#[serde(default)]
163+
user_gesture: bool,
160164
}
161165

162166
#[derive(Debug, Serialize)]
@@ -313,7 +317,7 @@ async fn handle_request(
313317
let parsed: SuggestionsPayload = serde_json::from_value(req.payload)
314318
.context("invalid payload for get_suggestions")?;
315319
let host = origin_to_host(&parsed.origin)?;
316-
let items = get_password_suggestions(db_path, &host).await?;
320+
let items = get_credential_suggestions(db_path, &host).await?;
317321
let payload = serde_json::to_value(SuggestionsResponse {
318322
items,
319323
suggesting_for: host,
@@ -425,6 +429,18 @@ async fn handle_request(
425429
serde_json::from_value(req.payload).context("invalid payload for get_totp")?;
426430
let host = origin_to_host(&parsed.origin)?;
427431

432+
let require_gesture = std::env::var("PERSONA_BRIDGE_REQUIRE_GESTURE")
433+
.map(|v| v != "0" && v.to_lowercase() != "false")
434+
.unwrap_or(true);
435+
if require_gesture && !parsed.user_gesture {
436+
warn!(
437+
origin = %parsed.origin,
438+
item_id = %parsed.item_id,
439+
"totp request rejected: user_gesture required but not provided"
440+
);
441+
return Err(anyhow!("user_gesture_required: totp must be triggered by explicit user action"));
442+
}
443+
428444
let master_password = std::env::var("PERSONA_MASTER_PASSWORD")
429445
.ok()
430446
.filter(|s| !s.trim().is_empty())
@@ -450,6 +466,10 @@ async fn handle_request(
450466
return Err(anyhow!("unsupported_credential_type"));
451467
}
452468

469+
if cred.url.is_none() {
470+
return Err(anyhow!("origin_binding_required: totp entries must have a URL set"));
471+
}
472+
453473
if !validate_origin_binding(&host, cred.url.as_deref()) {
454474
warn!(
455475
origin = %parsed.origin,
@@ -1048,7 +1068,7 @@ async fn compute_status(db_path: &PathBuf) -> Result<(bool, Option<String>)> {
10481068
Ok((locked, active_identity))
10491069
}
10501070

1051-
async fn get_password_suggestions(db_path: &PathBuf, host: &str) -> Result<Vec<SuggestionItem>> {
1071+
async fn get_credential_suggestions(db_path: &PathBuf, host: &str) -> Result<Vec<SuggestionItem>> {
10521072
let db = open_db(db_path).await?;
10531073
let repo = CredentialRepository::new(db);
10541074
let all = repo.find_all().await?;
@@ -1058,15 +1078,18 @@ async fn get_password_suggestions(db_path: &PathBuf, host: &str) -> Result<Vec<S
10581078
if !cred.is_active {
10591079
continue;
10601080
}
1061-
if cred.credential_type != CredentialType::Password {
1081+
let kind = match cred.credential_type {
1082+
CredentialType::Password => "password",
1083+
CredentialType::TwoFactor => "totp",
1084+
_ => continue,
1085+
};
1086+
1087+
if cred.url.is_none() {
10621088
continue;
10631089
}
10641090

10651091
// Calculate match strength based on URL similarity.
1066-
let match_strength = match cred.url.as_deref() {
1067-
Some(url) => compute_match_strength(host, url),
1068-
None => 0,
1069-
};
1092+
let match_strength = compute_match_strength(host, cred.url.as_deref().unwrap_or_default());
10701093

10711094
if match_strength == 0 {
10721095
continue;
@@ -1077,6 +1100,7 @@ async fn get_password_suggestions(db_path: &PathBuf, host: &str) -> Result<Vec<S
10771100
title: cred.name,
10781101
username_hint: cred.username,
10791102
match_strength,
1103+
credential_type: kind.to_string(),
10801104
});
10811105
}
10821106

cli/src/commands/totp.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ pub enum TotpCommand {
4646
/// Account name override
4747
#[arg(long)]
4848
account: Option<String>,
49+
/// Associate this TOTP with a website origin (enables browser extension matching)
50+
///
51+
/// Accepts full URL (https://github.com) or a bare host (github.com).
52+
#[arg(long)]
53+
url: Option<String>,
4954
/// Digits override
5055
#[arg(long)]
5156
digits: Option<u8>,
@@ -77,12 +82,13 @@ pub async fn execute(args: TotpArgs, config: &CliConfig) -> Result<()> {
7782
secret,
7883
issuer,
7984
account,
85+
url,
8086
digits,
8187
period,
8288
algorithm,
8389
} => {
8490
setup_totp(
85-
config, identity, name, qr, otpauth, secret, issuer, account, digits, period,
91+
config, identity, name, qr, otpauth, secret, issuer, account, url, digits, period,
8692
algorithm,
8793
)
8894
.await?
@@ -101,6 +107,7 @@ async fn setup_totp(
101107
secret: Option<String>,
102108
issuer_override: Option<String>,
103109
account_override: Option<String>,
110+
url: Option<String>,
104111
digits_override: Option<u8>,
105112
period_override: Option<u32>,
106113
algorithm_override: Option<String>,
@@ -137,6 +144,7 @@ async fn setup_totp(
137144
}
138145

139146
let final_template = template.finalize()?;
147+
let origin_url = url.map(|s| normalize_origin_url(&s)).transpose()?;
140148

141149
let credential_name = display_name
142150
.or_else(|| {
@@ -173,6 +181,9 @@ async fn setup_totp(
173181
.context("Failed to create TOTP credential")?;
174182

175183
credential.username = Some(final_template.account.clone());
184+
if let Some(url) = origin_url {
185+
credential.url = Some(url);
186+
}
176187
credential
177188
.metadata
178189
.insert("issuer".into(), final_template.issuer.clone());
@@ -205,6 +216,21 @@ async fn setup_totp(
205216
Ok(())
206217
}
207218

219+
fn normalize_origin_url(raw: &str) -> Result<String> {
220+
let trimmed = raw.trim();
221+
if trimmed.is_empty() {
222+
bail!("URL cannot be empty");
223+
}
224+
225+
let url = url::Url::parse(trimmed).or_else(|_| url::Url::parse(&format!("https://{trimmed}")))?;
226+
let scheme = url.scheme();
227+
let host = url
228+
.host_str()
229+
.ok_or_else(|| anyhow!("Invalid URL: missing host"))?;
230+
231+
Ok(format!("{scheme}://{host}"))
232+
}
233+
208234
async fn generate_codes(config: &CliConfig, id: Uuid, watch: bool) -> Result<()> {
209235
let mut service = init_service(config).await?;
210236
let credential = service

docs/BRIDGE_PROTOCOL.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
110110
"ok": true,
111111
"payload": {
112112
"server_version": "0.1.0",
113-
"capabilities": ["status", "pairing_request", "pairing_finalize", "get_suggestions", "request_fill"],
113+
"capabilities": ["status", "pairing_request", "pairing_finalize", "get_suggestions", "request_fill", "get_totp", "copy"],
114114
"pairing_required": true,
115115
"paired": false,
116116
"session_id": null,
@@ -231,7 +231,8 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
231231
"item_id": "uuid-v4",
232232
"title": "GitHub (Work)",
233233
"username_hint": "user@example.com",
234-
"match_strength": 100
234+
"match_strength": 100,
235+
"credential_type": "password"
235236
}
236237
]
237238
}
@@ -241,9 +242,9 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
241242
| match_strength | 含义 |
242243
|----------------|------|
243244
| 100 | 精确域名匹配 |
245+
| 90 | 子域名匹配 |
244246
| 80 | 域名包含匹配 |
245247
| 60 | 顶级域名匹配 |
246-
| 40 | 手动关联 |
247248

248249
### 6. request_fill - 请求填充
249250

@@ -284,13 +285,16 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
284285

285286
获取关联凭证的当前 TOTP 代码。
286287

288+
> 注意:为了进行 Origin 绑定,TOTP 条目必须设置 URL(否则返回 `origin_binding_required`)。
289+
287290
**请求:**
288291
```json
289292
{
290293
"type": "get_totp",
291294
"payload": {
292295
"origin": "https://github.com",
293-
"item_id": "uuid-v4"
296+
"item_id": "uuid-v4",
297+
"user_gesture": true
294298
}
295299
}
296300
```
@@ -349,7 +353,7 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
349353

350354
### User Gesture 要求
351355

352-
`request_fill` `copy` 操作要求:
356+
`request_fill` / `get_totp` / `copy` 操作要求:
353357

354358
1. 必须由用户明确操作触发(点击、键盘快捷键)
355359
2. 请求中应包含 `user_gesture: true` 表示这是用户主动操作
@@ -420,6 +424,7 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
420424
| `locked` | 保险库已锁定,需要解锁 |
421425
| `not_found` | 请求的资源不存在 |
422426
| `origin_mismatch` | Origin 不匹配 |
427+
| `origin_binding_required` | 条目未设置 URL,无法进行 Origin 绑定 |
423428
| `authentication_failed` | 认证失败 |
424429
| `user_confirmation_required` | 需要用户确认 |
425430
| `session_expired` | 会话已过期 |
@@ -435,7 +440,7 @@ Persona Native Messaging Bridge Protocol 用于浏览器扩展与本地 CLI/Desk
435440
| `PERSONA_DB_PATH` | 数据库路径 | `~/.persona/identities.db` |
436441
| `PERSONA_BRIDGE_STATE_DIR` | Bridge 状态目录(pairing/session) | `~/.persona/bridge` |
437442
| `PERSONA_BRIDGE_REQUIRE_PAIRING` | 是否强制 pairing + HMAC | `true` |
438-
| `PERSONA_BRIDGE_REQUIRE_GESTURE` | 是否强制 user_gesture(fill/copy) | `true` |
443+
| `PERSONA_BRIDGE_REQUIRE_GESTURE` | 是否强制 user_gesture(fill/totp/copy) | `true` |
439444
| `PERSONA_BRIDGE_AUTH_MAX_SKEW_MS` | HMAC 时间戳最大偏移(防重放) | `300000` |
440445

441446
### CLI 参数

0 commit comments

Comments
 (0)