feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup

Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token
read path design, vault transport split, and retire obsolete filetree smokes.
Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
Agent Board
2026-07-24 11:36:06 +08:00
parent b798f628ee
commit bc6f8488ee
41 changed files with 13072 additions and 2316 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "mnote-vault-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Local-first password vault store + agent token (no mnote-web)"
[dependencies]
base64 = "0.22"
hex = "0.4"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
time = { version = "0.3", features = ["formatting", "local-offset"] }
uuid = { version = "1", features = ["v4"] }
+899
View File
@@ -0,0 +1,899 @@
//! 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_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).
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<PathBuf, VaultError> {
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<Value, VaultError> {
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<Value> = 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<Value, VaultError> {
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<Value, VaultError> {
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<String, VaultError> {
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<String> {
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<String, String> = 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::<Vec<_>>()
.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<Value, VaultError> {
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()),
};
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<Value, VaultError> {
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(),
)
});
if playbook.is_human_required() && !force_refresh {
if let Some(sess) = record.login_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,
"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 <id> --cookie-header '…'",
"remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写",
"cli": format!("mnote-vault session --id {credential_id} --cookie-header '<Cookie>'"),
},
"transcriptHint": "需要人工验证后回写 session",
"transport": "local-core",
}));
}
if !force_refresh {
if let Some(sess) = record.login_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,
"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 '<Cookie>'"),
},
"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<String> = 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 '<Cookie>'"),
},
"transport": "local-core",
}));
}
let mut json_ok = false;
if let Ok(v) = serde_json::from_str::<Value>(&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()),
};
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(());
fn with_temp_workspace<F, R>(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);
});
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Vault errors without axum — map to HTTP-like codes for CLI / vaultd.
use serde_json::Value;
use std::fmt;
/// Logical status (subset used by vault_store).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VaultStatus {
BadRequest,
NotFound,
Conflict,
Unauthorized,
Forbidden,
Locked,
Unavailable,
Internal,
}
impl VaultStatus {
pub fn as_u16(self) -> u16 {
match self {
Self::BadRequest => 400,
Self::Unauthorized => 401,
Self::Forbidden => 403,
Self::NotFound => 404,
Self::Conflict => 409,
Self::Locked => 423,
Self::Unavailable => 503,
Self::Internal => 500,
}
}
}
#[derive(Debug, Clone)]
pub struct VaultError {
pub status: VaultStatus,
pub code: &'static str,
pub message: String,
pub details: Option<Value>,
}
impl VaultError {
pub fn new(status: VaultStatus, code: &'static str, message: impl Into<String>) -> Self {
Self {
status,
code,
message: message.into(),
details: None,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(VaultStatus::BadRequest, "bad_request", message)
}
pub fn bad_request_code(code: &'static str, message: impl Into<String>) -> Self {
Self::new(VaultStatus::BadRequest, code, message)
}
pub fn not_found_code(code: &'static str, message: impl Into<String>) -> Self {
Self::new(VaultStatus::NotFound, code, message)
}
pub fn unauthorized_code(code: &'static str, message: impl Into<String>) -> Self {
Self::new(VaultStatus::Unauthorized, code, message)
}
pub fn forbidden_code(code: &'static str, message: impl Into<String>) -> Self {
Self::new(VaultStatus::Forbidden, code, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(VaultStatus::Internal, "internal_error", message)
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
/// Compatible with mnote-web `WebError::code()` (store was copied as-is).
pub fn code(&self) -> &str {
self.code
}
pub fn status(&self) -> VaultStatus {
self.status
}
}
impl fmt::Display for VaultError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for VaultError {}
// Compatibility aliases used by store.rs (former WebError + StatusCode patterns).
impl VaultError {
/// Former `WebError::new(StatusCode::NOT_FOUND, code, msg)`.
pub fn from_status(
status: VaultStatus,
code: &'static str,
message: impl Into<String>,
) -> Self {
Self::new(status, code, message)
}
}
@@ -0,0 +1,28 @@
//! Minimal frontmatter splitter (copied from mnote-web local_markdown_parser).
/// Split YAML frontmatter between leading `---\\n` and next `\\n---\\n`.
pub fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown);
if !normalized.starts_with("---\n") {
return (None, normalized);
}
let rest = &normalized[4..];
if let Some(end) = rest.find("\n---\n") {
let frontmatter = rest[..end].to_string();
let body = &rest[end + 5..];
return (Some(frontmatter), body);
}
(None, normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_basic() {
let (fm, body) = split_frontmatter("---\nid: x\n---\n\nhello\n");
assert_eq!(fm.as_deref(), Some("id: x"));
assert!(body.contains("hello"));
}
}
+22
View File
@@ -0,0 +1,22 @@
//! Local-first password vault core (12-2).
//!
//! File SSOT under `{workspace}/.mnote/vault/`. Read path for agents does **not**
//! require mnote-web — use token + list/get/resolve APIs in this crate / `mnote-vault` CLI.
pub mod ai;
pub mod error;
pub mod frontmatter;
pub mod store;
pub mod token;
pub use ai::{
ai_vault_actor_id, ai_vault_workspace_root, ensure_ai_vault_workspace, get_ai_vault_item,
list_ai_vault_items, login_ai_vault_credential, put_ai_vault_session, resolve_ai_vault_secret,
};
pub use error::{VaultError, VaultStatus};
pub use store::{VaultCredentialRecord, VaultItemStatus};
pub use token::{
default_hmac_key_path, default_sock_path, default_token_path, issue_token, load_or_create_hmac_key,
read_token_from_env_or_file, require_actor, require_scope, verify_token, write_token_file,
VaultTokenClaims,
};
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
//! Agent capability tokens for local vault access (12-2).
//!
//! Format: `mnv1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
//! Token is **not** a master key — only authorizes list/get/resolve against vaultd/CLI.
use crate::error::{VaultError, VaultStatus};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
pub const TOKEN_VERSION: u32 = 1;
pub const DEFAULT_TOKEN_DIR: &str = ".config/mnote/vault-tokens";
pub const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vaultd-hmac.key";
pub const DEFAULT_ACTOR: &str = "mnote-e2e";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultTokenClaims {
pub v: u32,
pub iss: String,
pub aud: String,
pub sub: String,
pub actor: String,
pub scope: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
pub iat: u64,
/// 0 = no expiry
pub exp: u64,
pub jti: String,
}
#[derive(Debug, Clone)]
pub struct IssuedToken {
pub token: String,
pub claims: VaultTokenClaims,
}
pub fn default_hmac_key_path() -> PathBuf {
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
}
pub fn default_token_path() -> PathBuf {
dirs_path_home()
.join(DEFAULT_TOKEN_DIR)
.join("default.token")
}
/// Default UDS path for mnote-vaultd (12-2 §5.1).
/// Order: `MNOTE_VAULT_SOCK` → `$XDG_RUNTIME_DIR/mnote-vaultd.sock` → `/tmp/mnote-vaultd-$UID.sock`.
pub fn default_sock_path() -> PathBuf {
if let Ok(p) = std::env::var("MNOTE_VAULT_SOCK") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
let runtime = runtime.trim();
if !runtime.is_empty() {
return PathBuf::from(runtime).join("mnote-vaultd.sock");
}
}
let uid = std::env::var("UID")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or_else(current_uid);
PathBuf::from(format!("/tmp/mnote-vaultd-{uid}.sock"))
}
fn current_uid() -> u32 {
#[cfg(unix)]
{
extern "C" {
fn getuid() -> u32;
}
// SAFETY: getuid has no preconditions.
unsafe { getuid() }
}
#[cfg(not(unix))]
{
0
}
}
fn dirs_path_home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, VaultError> {
if path.exists() {
let raw = fs::read_to_string(path).map_err(|e| {
VaultError::bad_request_code(
"vault_hmac_key_read_failed",
format!("无法读取 HMAC key {}: {e}", path.display()),
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
return Err(VaultError::bad_request_code(
"vault_hmac_key_invalid",
"HMAC key 过短",
));
}
return hex::decode(hex).map_err(|e| {
VaultError::bad_request_code(
"vault_hmac_key_invalid",
format!("HMAC key 非 hex: {e}"),
)
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
VaultError::bad_request_code(
"vault_hmac_key_write_failed",
format!("无法创建目录: {e}"),
)
})?;
}
let mut bytes = [0u8; 32];
getrandom_fill(&mut bytes)?;
let hex = hex::encode(bytes);
fs::write(path, format!("{hex}\n")).map_err(|e| {
VaultError::bad_request_code(
"vault_hmac_key_write_failed",
format!("无法写入 HMAC key: {e}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(bytes.to_vec())
}
fn getrandom_fill(buf: &mut [u8]) -> Result<(), VaultError> {
use std::io::Read;
// Prefer /dev/urandom for zero extra deps if getrandom crate not used.
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
VaultError::internal(format!("open /dev/urandom: {e}"))
})?;
f.read_exact(buf)
.map_err(|e| VaultError::internal(format!("read urandom: {e}")))?;
Ok(())
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn issue_token(
hmac_key: &[u8],
sub: &str,
actor: &str,
scopes: &[&str],
ttl_secs: Option<u64>,
workspace: Option<String>,
) -> Result<IssuedToken, VaultError> {
let iat = now_unix();
let exp = match ttl_secs {
Some(0) | None => 0,
Some(ttl) => iat.saturating_add(ttl),
};
let claims = VaultTokenClaims {
v: TOKEN_VERSION,
iss: "mnote-vaultd".into(),
aud: "local-vaultd".into(),
sub: sub.trim().to_string(),
actor: actor.trim().to_string(),
scope: scopes.iter().map(|s| (*s).to_string()).collect(),
workspace,
iat,
exp,
jti: Uuid::new_v4().to_string(),
};
let token = encode_token(hmac_key, &claims)?;
Ok(IssuedToken { token, claims })
}
pub fn encode_token(hmac_key: &[u8], claims: &VaultTokenClaims) -> Result<String, VaultError> {
let payload = serde_json::to_vec(claims).map_err(|e| {
VaultError::internal(format!("token serialize: {e}"))
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
let mut mac = HmacSha256::new_from_slice(hmac_key)
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
Ok(format!("mnv1.{payload_b64}.{sig_b64}"))
}
pub fn verify_token(hmac_key: &[u8], token: &str) -> Result<VaultTokenClaims, VaultError> {
let token = token.trim();
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 || parts[0] != "mnv1" {
return Err(VaultError::unauthorized_code(
"vault_token_invalid",
"token 格式无效(期望 mnv1.<payload>.<sig>",
));
}
let payload_b64 = parts[1];
let sig_b64 = parts[2];
let mut mac = HmacSha256::new_from_slice(hmac_key)
.map_err(|e| VaultError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let expected = mac.finalize().into_bytes();
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
VaultError::unauthorized_code("vault_token_invalid", "token 签名解码失败")
})?;
if sig.len() != expected.len()
|| sig
.iter()
.zip(expected.iter())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
!= 0
{
return Err(VaultError::unauthorized_code(
"vault_token_invalid",
"token 签名校验失败",
));
}
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
VaultError::unauthorized_code("vault_token_invalid", "token payload 解码失败")
})?;
let claims: VaultTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
VaultError::unauthorized_code("vault_token_invalid", "token payload JSON 无效")
})?;
if claims.v != TOKEN_VERSION {
return Err(VaultError::unauthorized_code(
"vault_token_invalid",
format!("不支持的 token 版本 {}", claims.v),
));
}
if claims.aud != "local-vaultd" {
return Err(VaultError::unauthorized_code(
"vault_token_invalid",
"token aud 不匹配",
));
}
if claims.exp != 0 && now_unix() > claims.exp {
return Err(VaultError::unauthorized_code(
"vault_token_expired",
"token 已过期",
));
}
Ok(claims)
}
pub fn require_scope(claims: &VaultTokenClaims, need: &str) -> Result<(), VaultError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") {
return Ok(());
}
Err(VaultError::forbidden_code(
"vault_scope_denied",
format!("token 缺少 scope: {need}"),
))
}
pub fn require_actor(claims: &VaultTokenClaims, expected: &str) -> Result<(), VaultError> {
if claims.actor.trim() == expected.trim() {
return Ok(());
}
Err(VaultError::forbidden_code(
"vault_actor_mismatch",
format!(
"token.actor={} 与 AI 本 actor={} 不一致",
claims.actor, expected
),
))
}
pub fn read_token_from_env_or_file() -> Result<String, VaultError> {
if let Ok(t) = std::env::var("MNOTE_VAULT_TOKEN") {
let t = t.trim().to_string();
if !t.is_empty() {
return Ok(t);
}
}
let path = std::env::var("MNOTE_VAULT_TOKEN_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| default_token_path());
if !path.exists() {
return Err(VaultError::new(
VaultStatus::Unauthorized,
"vault_token_missing",
format!(
"未找到 token(设 MNOTE_VAULT_TOKEN 或运行 issue-token)。期望文件: {}",
path.display()
),
));
}
let raw = fs::read_to_string(&path).map_err(|e| {
VaultError::unauthorized_code(
"vault_token_missing",
format!("无法读取 {}: {e}", path.display()),
)
})?;
let t = raw.trim().to_string();
if t.is_empty() {
return Err(VaultError::unauthorized_code(
"vault_token_missing",
"token 文件为空",
));
}
Ok(t)
}
pub fn write_token_file(path: &Path, token: &str) -> Result<(), VaultError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
VaultError::bad_request_code(
"vault_token_write_failed",
format!("无法创建目录: {e}"),
)
})?;
}
fs::write(path, format!("{}\n", token.trim())).map_err(|e| {
VaultError::bad_request_code(
"vault_token_write_failed",
format!("无法写入 token: {e}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_verify_roundtrip() {
let key = b"0123456789abcdef0123456789abcdef";
let issued = issue_token(
key,
"agent:test",
"mnote-e2e",
&["list", "get", "resolve"],
Some(3600),
None,
)
.unwrap();
let claims = verify_token(key, &issued.token).unwrap();
assert_eq!(claims.actor, "mnote-e2e");
require_scope(&claims, "resolve").unwrap();
assert!(require_scope(&claims, "admin").is_err());
}
#[test]
fn bad_sig_fails() {
let key = b"0123456789abcdef0123456789abcdef";
let issued = issue_token(key, "a", "mnote-e2e", &["list"], None, None).unwrap();
let mut t = issued.token;
t.push('x');
assert!(verify_token(key, &t).is_err());
}
}