//! AI password-book path (list / get / resolve / login / session) without mnote-web. use crate::error::VaultError; use crate::store::{ self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials, load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher, project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session, resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession, }; use crate::token::DEFAULT_ACTOR; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; const DEFAULT_DATA_BASE: &str = "/mnt/Data1T/Mnote_data"; pub fn ai_vault_actor_id() -> String { std::env::var("MNOTE_AI_VAULT_ACTOR") .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .unwrap_or_else(|| DEFAULT_ACTOR.to_string()) } /// Encode actor id for managed path segment (aligned with mnote-web). /// /// 不保留 `.` / `/` 等路径元字符,避免 actor=`..` 时穿越 `users/` 目录。 pub fn encode_actor_segment(actor_id: &str) -> String { actor_id .chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c.to_string(), other => format!("~{:02x}", other as u32), }) .collect() } pub fn managed_data_base() -> PathBuf { std::env::var("MNOTE_DATA_DIR") .or_else(|_| std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR")) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_BASE)) } pub fn ai_vault_workspace_root() -> PathBuf { if let Ok(p) = std::env::var("MNOTE_VAULT_WORKSPACE") { let p = p.trim(); if !p.is_empty() { return PathBuf::from(p); } } let actor = encode_actor_segment(&ai_vault_actor_id()); managed_data_base() .join("users") .join(actor) .join("workspaces") .join("my-space") } pub fn ensure_ai_vault_workspace() -> Result { let root = ai_vault_workspace_root(); if !root.exists() { std::fs::create_dir_all(root.join(".mnote")).map_err(|e| { VaultError::bad_request_code( "vault_ai_root_unavailable", format!("无法创建 AI 密码本工作区 {}: {e}", root.display()), ) })?; } let root = root.canonicalize().map_err(|e| { VaultError::bad_request_code( "vault_ai_root_unavailable", format!("无法访问 AI 密码本工作区: {e}"), ) })?; let _ = ensure_vault_directories(&root)?; Ok(root) } fn normalize_secret_field(field: &str) -> Result<&'static str, VaultError> { match field.trim() { "password" => Ok("password"), "apikey" | "apiKey" | "api_key" => Ok("apikey"), "token" => Ok("token"), "username" => Ok("username"), "email" => Ok("email"), other => Err(VaultError::bad_request_code( "vault_resolve_field_invalid", format!("不支持字段: {other};允许 password|apikey|token|username|email"), )), } } pub fn list_ai_vault_items(status: VaultItemStatus) -> Result { let root = ensure_ai_vault_workspace()?; let (index, items) = list_credentials(&root, status)?; let book = load_cipher_book(&root).ok(); let book_ref = book.as_ref(); let projected: Vec = items .iter() .map(|e| project_list_entry_with_cipher(e, book_ref)) .collect(); Ok(json!({ "schema": "mnote.vault.list.v1", "status": status.as_str(), "revision": index.revision, "updatedAt": index.updated_at, "items": projected, "isAiVault": true, "aiVaultActorId": ai_vault_actor_id(), "vaultRole": "ai", "transport": "local-core", })) } pub fn get_ai_vault_item(credential_id: &str) -> Result { let root = ensure_ai_vault_workspace()?; let record = get_credential(&root, credential_id)?; Ok(json!({ "item": project_item_l0_with_cipher(&record, Some(&root)), "isAiVault": true, "aiVaultActorId": ai_vault_actor_id(), "transport": "local-core", })) } pub fn resolve_ai_vault_secret( credential_id: &str, field: &str, actor: &str, request_id: Option<&str>, account_id: Option<&str>, secret_id: Option<&str>, ) -> Result { let field_norm = normalize_secret_field(field)?; let account_id = account_id.map(str::trim).filter(|v| !v.is_empty()); let secret_id = secret_id.map(str::trim).filter(|v| !v.is_empty()); let root = ensure_ai_vault_workspace()?; let record = match get_credential(&root, credential_id) { Ok(r) => r, Err(err) => { let _ = append_vault_audit( &root, "resolve", actor, credential_id, Some(field_norm), request_id, false, ); return Err(err); } }; if record.status != VaultItemStatus::Active { let _ = append_vault_audit( &root, "resolve", actor, credential_id, Some(field_norm), request_id, false, ); return Err(VaultError::bad_request_code( "vault_resolve_inactive", "只能 resolve 在用条目", )); } let secret = resolve_record_secret_field(&record, field_norm, account_id, secret_id); let secret_proj = match project_secret_revealed(&root, secret) { Ok(v) => v, Err(err) => { let _ = append_vault_audit( &root, "resolve", actor, credential_id, Some(field_norm), request_id, false, ); return Err(err); } }; let _ = append_vault_audit( &root, "resolve", actor, credential_id, Some(field_norm), request_id, true, ); let value = secret_proj .get("value") .and_then(Value::as_str) .unwrap_or(""); let state = secret_proj .get("state") .and_then(Value::as_str) .unwrap_or("absent"); let mut out = json!({ "schema": "mnote.vault.resolve.v1", "id": credential_id, "field": field_norm, "resolved": state == "revealed", "state": state, "value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null }, "usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])), "missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])), "template": secret_proj.get("template").cloned().unwrap_or(Value::Null), "aiVaultActorId": ai_vault_actor_id(), "transcriptHint": format!("已解析 {field_norm}"), "note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG", "transport": "local-core", }); if let Some(aid) = account_id { out["accountId"] = json!(aid); } if let Some(sid) = secret_id { out["secretId"] = json!(sid); } let _ = store::vault_root(Path::new(&root)); Ok(out) } fn default_session_expires_hours() -> i64 { std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS") .ok() .and_then(|s| s.trim().parse().ok()) .filter(|h| *h > 0) .unwrap_or(168) // 7 days } fn session_expires_rfc3339_from_now() -> String { let hours = default_session_expires_hours(); let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours); t.format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| now_rfc3339()) } fn origin_from_credential_url(url: Option<&str>) -> Result { let raw = url .map(str::trim) .filter(|s| !s.is_empty()) .ok_or_else(|| { VaultError::bad_request_code( "vault_login_url_required", "条目缺少 url,无法推导登录 origin", ) })?; let with_scheme = if raw.contains("://") { raw.to_string() } else { format!("https://{raw}") }; let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| { VaultError::bad_request_code( "vault_login_url_invalid", format!("无法解析 url: {e}"), ) })?; if let Some(port) = parsed.port() { return Ok(format!( "{}://{}:{}", parsed.scheme(), parsed.host_str().unwrap_or(""), port )); } let origin = format!( "{}://{}", parsed.scheme(), parsed.host_str().unwrap_or("") ); if origin.ends_with("://") { return Err(VaultError::bad_request_code( "vault_login_url_invalid", "url 缺少 host", )); } Ok(origin) } fn pick_login_account( workspace_root: &Path, record: &VaultCredentialRecord, preferred: &str, ) -> String { let pref = preferred.trim().to_ascii_lowercase(); let expand = |raw: Option<&str>| -> Option { let t = raw.map(str::trim).filter(|s| !s.is_empty())?; match resolve_secret_with_cipher_book(workspace_root, Some(t)) { Ok(Some(resolved)) => { let v = resolved.value.trim().to_string(); if v.is_empty() { None } else { Some(v) } } _ => Some(t.to_string()), } }; let email = expand(record.email.as_deref()).or_else(|| { record .accounts .first() .and_then(|a| expand(a.email.as_deref())) }); let user = expand(record.username.as_deref()).or_else(|| { record .accounts .first() .and_then(|a| expand(a.username.as_deref())) }); match pref.as_str() { "email" => email.or(user).unwrap_or_default(), "username" => user.or(email).unwrap_or_default(), _ => email.or(user).unwrap_or_default(), } } fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String { use std::collections::BTreeMap; let mut map: BTreeMap = BTreeMap::new(); for part in existing.split(';') { let p = part.trim(); if p.is_empty() { continue; } if let Some((k, v)) = p.split_once('=') { map.insert(k.trim().to_string(), v.trim().to_string()); } } for sc in set_cookies { let first = sc.split(';').next().unwrap_or("").trim(); if let Some((k, v)) = first.split_once('=') { map.insert(k.trim().to_string(), v.trim().to_string()); } } map.iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join("; ") } /// Write browser-captured cookies into AI password book (no mnote-web). pub fn put_ai_vault_session( credential_id: &str, cookie_header: &str, expires_at: Option<&str>, source: &str, actor: &str, request_id: Option<&str>, ) -> Result { let root = ensure_ai_vault_workspace()?; let cookie = cookie_header.trim(); if cookie.is_empty() { return Err(VaultError::bad_request_code( "vault_session_cookie_required", "cookieHeader 不能为空", )); } let now = time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| now_rfc3339()); let expires = expires_at .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .unwrap_or_else(session_expires_rfc3339_from_now); let session = VaultLoginSession { cookie_header: Some(cookie.to_string()), expires_at: Some(expires.clone()), last_login_at: Some(now), source: Some(source.trim().to_string()), ..Default::default() }; let record = put_login_session(&root, credential_id, session)?; let _ = append_vault_audit( &root, "session_put", actor, credential_id, Some(source), request_id, true, ); Ok(json!({ "schema": "mnote.vault.session.v1", "id": credential_id, "ok": true, "hasLoginSession": true, "expiresAt": expires, "source": source, "item": project_item_l0_with_cipher(&record, Some(&root)), "transcriptHint": "已回写登录态", "transport": "local-core", })) } /// Local login against AI password book (no mnote-web): /// reuse fresh session → else api_first password login → save session. /// Cloudflare / captcha → human_required (agent uses browser tools then `session`). pub fn login_ai_vault_credential( credential_id: &str, force_refresh: bool, actor: &str, request_id: Option<&str>, ) -> Result { let root = ensure_ai_vault_workspace()?; let mut record = get_credential(&root, credential_id)?; if record.status != VaultItemStatus::Active { return Err(VaultError::bad_request_code( "vault_login_inactive", "只能登录在用条目", )); } if record.login_playbook.is_none() { let pb = VaultLoginPlaybook::default_for_credential( record.email.as_deref(), record.username.as_deref(), ); record = put_login_playbook(&root, credential_id, pb)?; } let playbook = record.login_playbook.clone().unwrap_or_else(|| { VaultLoginPlaybook::default_for_credential( record.email.as_deref(), record.username.as_deref(), ) }); // Prefer per-account session files (12-3); frontmatter is fallback inside resolve. let resolved_session = resolve_login_session(&root, &record, None).ok().flatten(); if playbook.is_human_required() && !force_refresh { if let Some(sess) = resolved_session.as_ref() { if login_session_is_fresh(sess) { let cookie = sess.cookie_header.clone().unwrap_or_default(); let _ = append_vault_audit( &root, "login_reuse", actor, credential_id, Some("session"), request_id, true, ); return Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": true, "mode": "session", "cookieHeader": cookie, "expiresAt": sess.expires_at, "accountId": sess.account_id, "loginPlaybook": playbook, "transcriptHint": "已复用登录态", "note": "playbook=human_required;有可用 session 直接复用", "transport": "local-core", })); } } return Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": false, "mode": "human_required", "ok": false, "code": "vault_login_human_required", "message": "需要人完成验证码/Cloudflare 后回写登录态", "loginPlaybook": playbook, "humanInstructions": { "local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 mnote-vault session --id --cookie-header '…'", "remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写", "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), }, "transcriptHint": "需要人工验证后回写 session", "transport": "local-core", })); } if !force_refresh { if let Some(sess) = resolved_session.as_ref() { if login_session_is_fresh(sess) { let cookie = sess.cookie_header.clone().unwrap_or_default(); let _ = append_vault_audit( &root, "login_reuse", actor, credential_id, Some("session"), request_id, true, ); return Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": true, "mode": "session", "cookieHeader": cookie, "expiresAt": sess.expires_at, "source": sess.source, "accountId": sess.account_id, "loginPlaybook": { "mode": playbook.mode, "preferredAccount": playbook.preferred_account, }, "accountHint": pick_login_account(&root, &record, &playbook.preferred_account), "url": record.url, "transcriptHint": "已复用登录态", "transport": "local-core", })); } } } let password_resolved = resolve_ai_vault_secret( credential_id, "password", actor, request_id, None, None, )?; if password_resolved .get("resolved") .and_then(Value::as_bool) != Some(true) { return Err(VaultError::bad_request_code( "vault_login_no_password", "条目无可用 password,无法自动登录", )); } let password = password_resolved .get("value") .and_then(Value::as_str) .unwrap_or("") .to_string(); let account = pick_login_account(&root, &record, &playbook.preferred_account); if account.is_empty() { return Err(VaultError::bad_request_code( "vault_login_no_account", "条目缺少 email/username", )); } let mode = playbook.mode.trim().to_ascii_lowercase(); if mode == "browser" { return Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": false, "mode": "browser", "ok": false, "code": "vault_login_browser_required", "message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写", "loginPlaybook": playbook, "account": account, "passwordResolved": true, "url": record.url, "transcriptHint": "需要浏览器按 playbook 登录后回写 session", "humanInstructions": { "local": "chrome-bridge 登录后 mnote-vault session", "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), }, "transport": "local-core", })); } // api_first (default) — outbound HTTP only; does not need mnote-web. let origin = origin_from_credential_url(record.url.as_deref())?; let api_path = playbook .api_path .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or("/api/auth"); let login_url = if api_path.starts_with("http") { api_path.to_string() } else { format!( "{}{}", origin.trim_end_matches('/'), if api_path.starts_with('/') { api_path.to_string() } else { format!("/{api_path}") } ) }; let body = json!({ "action": "auth:signIn", "args": { "provider": "password", "params": { "password": password, "flow": "signIn", "account": account, "email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null }, "name": if account.contains('@') { account.split('@').next().unwrap_or(&account) } else { account.as_str() }, } } }); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| { VaultError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}")) })?; let response = client .post(&login_url) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&body) .send() .map_err(|e| { let msg = e.to_string(); if msg.to_ascii_lowercase().contains("cloudflare") || msg.to_ascii_lowercase().contains("403") { VaultError::bad_request_code( "vault_login_human_required", format!("登录请求失败,可能需人机验证: {e}"), ) } else { VaultError::bad_request_code( "vault_login_http_failed", format!("登录请求失败: {e}"), ) } })?; let status = response.status(); let set_cookies: Vec = response .headers() .get_all(reqwest::header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_string)) .collect(); let resp_text = response.text().unwrap_or_default(); let lower = resp_text.to_ascii_lowercase(); if status.as_u16() == 403 || lower.contains("cloudflare") || lower.contains("cf-challenge") || lower.contains("captcha") || lower.contains("just a moment") { let _ = append_vault_audit( &root, "login_challenge", actor, credential_id, Some("human"), request_id, false, ); return Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": false, "mode": "human_required", "ok": false, "code": "vault_login_human_required", "message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session", "httpStatus": status.as_u16(), "loginPlaybook": playbook, "url": record.url, "transcriptHint": "需要人工验证后回写 session", "humanInstructions": { "local": "chrome-bridge 完成验证登录 → mnote-vault session", "cli": format!("mnote-vault session --id {credential_id} --cookie-header ''"), }, "transport": "local-core", })); } let mut json_ok = false; if let Ok(v) = serde_json::from_str::(&resp_text) { if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) { json_ok = true; } if let Some(err) = v.get("error").and_then(Value::as_str) { let _ = append_vault_audit( &root, "login_failed", actor, credential_id, Some("api"), request_id, false, ); return Err(VaultError::bad_request_code( "vault_login_rejected", format!("远端登录拒绝: {err}"), )); } } if !status.is_success() && !json_ok && set_cookies.is_empty() { let _ = append_vault_audit( &root, "login_failed", actor, credential_id, Some("api"), request_id, false, ); return Err(VaultError::bad_request_code( "vault_login_rejected", format!("远端登录失败 HTTP {}", status.as_u16()), )); } let cookie_header = merge_set_cookie_into_header("", &set_cookies); if cookie_header.is_empty() { return Err(VaultError::bad_request_code( "vault_login_no_cookie", "登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写", )); } let now = time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "now".into()); let expires = session_expires_rfc3339_from_now(); let session = VaultLoginSession { cookie_header: Some(cookie_header.clone()), expires_at: Some(expires.clone()), last_login_at: Some(now), source: Some("api".into()), ..Default::default() }; let _ = put_login_session(&root, credential_id, session)?; let _ = append_vault_audit( &root, "login_ok", actor, credential_id, Some("api"), request_id, true, ); Ok(json!({ "schema": "mnote.vault.login.v1", "id": credential_id, "reused": false, "mode": "api", "ok": true, "cookieHeader": cookie_header, "expiresAt": expires, "account": account, "url": record.url, "loginPlaybook": { "mode": playbook.mode, "preferredAccount": playbook.preferred_account, }, "transcriptHint": "已登录并保存登录态", "note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh", "transport": "local-core", })) } #[cfg(test)] mod tests { use super::*; use crate::store::{ create_credential, purge_credential, soft_delete_credential, VaultCreateInput, }; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; static AI_TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] fn encode_actor_segment_rejects_path_traversal_dots() { // `..` 不得原样保留,否则 join("users", "..") 可穿越目录。 let encoded = encode_actor_segment(".."); assert!(!encoded.contains('.'), "encoded={encoded}"); assert_eq!(encoded, "~2e~2e"); let dotted = encode_actor_segment("a.b"); assert!(!dotted.contains('.'), "encoded={dotted}"); assert_eq!(dotted, "a~2eb"); } fn with_temp_workspace(f: F) -> R where F: FnOnce(&Path) -> R, { let _guard = AI_TEST_LOCK.lock().unwrap(); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let root = std::env::temp_dir().join(format!("mnote-vault-ai-session-{nanos}")); std::fs::create_dir_all(&root).unwrap(); std::env::set_var("MNOTE_VAULT_WORKSPACE", &root); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(root.as_path()))); std::env::remove_var("MNOTE_VAULT_WORKSPACE"); let _ = std::fs::remove_dir_all(&root); match result { Ok(v) => v, Err(payload) => std::panic::resume_unwind(payload), } } #[test] fn put_session_then_login_reuses_without_http() { with_temp_workspace(|root| { let created = create_credential( root, VaultCreateInput { title: "session-reuse-core".into(), url: Some("https://example.com/auth".into()), email: Some("a@example.com".into()), password: Some("secret-pass".into()), tags: vec!["ai-shared".into()], ..Default::default() }, ) .expect("create"); let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2)) .format(&time::format_description::well_known::Rfc3339) .unwrap(); let put = put_ai_vault_session( &created.id, "mnote_session=abc; other=1", Some(&expires), "human_bridge", "tester", Some("req-session"), ) .expect("session put"); assert_eq!(put["ok"], true); assert_eq!(put["hasLoginSession"], true); assert_eq!(put["transport"], "local-core"); let login = login_ai_vault_credential(&created.id, false, "tester", None) .expect("login reuse"); assert_eq!(login["reused"], true); assert_eq!(login["mode"], "session"); assert_eq!(login["cookieHeader"], "mnote_session=abc; other=1"); assert_eq!(login["transport"], "local-core"); let _ = soft_delete_credential(root, &created.id); let _ = purge_credential(root, &created.id); }); } #[test] fn human_required_without_session_returns_instructions() { with_temp_workspace(|root| { let created = create_credential( root, VaultCreateInput { title: "human-required-core".into(), url: Some("https://example.com/login".into()), email: Some("h@example.com".into()), password: Some("pw".into()), tags: vec!["ai-shared".into()], ..Default::default() }, ) .expect("create"); let mut pb = VaultLoginPlaybook::default_for_credential(Some("h@example.com"), None); pb.mode = "human_required".into(); let _ = put_login_playbook(root, &created.id, pb).expect("playbook"); let login = login_ai_vault_credential(&created.id, false, "tester", None) .expect("login human"); assert_eq!(login["reused"], false); assert_eq!(login["mode"], "human_required"); assert_eq!(login["ok"], false); assert_eq!(login["code"], "vault_login_human_required"); assert!(login["humanInstructions"]["cli"].as_str().unwrap().contains(&created.id)); let _ = soft_delete_credential(root, &created.id); let _ = purge_credential(root, &created.id); }); } #[test] fn put_session_rejects_empty_cookie() { with_temp_workspace(|root| { let created = create_credential( root, VaultCreateInput { title: "empty-cookie".into(), email: Some("e@example.com".into()), password: Some("pw".into()), tags: vec!["ai-shared".into()], ..Default::default() }, ) .expect("create"); let err = put_ai_vault_session(&created.id, " ", None, "api", "tester", None) .expect_err("empty cookie"); assert_eq!(err.code, "vault_session_cookie_required"); let _ = soft_delete_credential(root, &created.id); let _ = purge_credential(root, &created.id); }); } }