Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,41 @@
[package]
name = "nomifun-secret"
version.workspace = true
edition.workspace = true
[dependencies]
# AES-GCM vault reuses nomifun-common::crypto (encrypt_string/decrypt_string)
# per P2 design ruling ⑦ — single shared AES-256-GCM implementation.
nomifun-common.workspace = true
# Compile-time embedded Public Suffix List for offline eTLD+1 domain binding.
psl.workspace = true
thiserror.workspace = true
# Ephemeral random key generation (tests / headless fallback callers).
getrandom.workspace = true
# X2 vault persistence: serializable records (ciphertext stays encrypted on disk).
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
# X2 `web` feature: the per-pet secret CRUD service + axum routes. Gated so the
# agent-layer consumers (nomi-browser / nomi-browser-engine) that only use the
# pure SecretStore/SecretValue do NOT pull axum / auth / api-types. Enabled by
# nomifun-app, which mounts the routes.
nomifun-api-types = { workspace = true, optional = true }
nomifun-auth = { workspace = true, optional = true }
axum = { workspace = true, optional = true }
[features]
default = []
web = ["dep:nomifun-api-types", "dep:nomifun-auth", "dep:axum"]
[dev-dependencies]
# X2 vault round-trip tests use a temp per-pet dir.
tempfile.workspace = true
# Route tests (web feature) exercise the router via tower oneshot.
tokio = { workspace = true, features = ["macros", "rt"] }
tower = { workspace = true, features = ["util"] }
http-body-util.workspace = true
nomifun-api-types.workspace = true
nomifun-auth.workspace = true
axum.workspace = true
@@ -0,0 +1,209 @@
//! eTLD+1 (registrable-domain) extraction and origin host normalization.
//!
//! Domain binding (DESIGN §4 / §16, ruling ⑦) uses the **eTLD+1** — the
//! registrable domain one label below the public suffix — as the equivalence
//! key. We use the [`psl`] crate, which embeds Mozilla's Public Suffix List at
//! compile time (fully offline) and therefore handles multi-level suffixes
//! correctly: `a.co.uk` and `b.co.uk` have *distinct* eTLD+1s (`a.co.uk` vs
//! `b.co.uk`) because `co.uk` is itself a public suffix — a naive
//! "take the last two labels" would wrongly collapse them to `co.uk`.
/// Extract the host component from an origin or URL-ish string and lowercase it.
///
/// Accepts bare hosts (`x.com`), full origins (`https://sub.x.com:8443`), and
/// values with a path/userinfo. Scheme, port, userinfo, and path are stripped;
/// the host is ASCII-lowercased. Returns `None` if no plausible host remains.
///
/// This is deliberately tolerant rather than a strict URL parser: the only
/// security decision downstream is the eTLD+1 comparison, and a malformed host
/// simply fails to produce an eTLD+1 (fail-closed).
pub fn host_of(origin: &str) -> Option<String> {
let s = origin.trim();
if s.is_empty() {
return None;
}
// Strip scheme: everything before "://".
let after_scheme = match s.split_once("://") {
Some((_scheme, rest)) => rest,
None => s,
};
// The authority is everything up to the first '/', '?', or '#'.
let authority = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme);
// Strip userinfo (user:pass@host).
let host_port = match authority.rsplit_once('@') {
Some((_userinfo, hp)) => hp,
None => authority,
};
// Strip port. Guard against IPv6 literals like "[::1]:8080".
let host = if let Some(stripped) = host_port.strip_prefix('[') {
// IPv6: take up to the closing ']'.
stripped.split(']').next().unwrap_or(stripped)
} else if let Some((h, port)) = host_port.rsplit_once(':') {
// Only treat the suffix as a port if it is all digits; otherwise the
// ':' was not a port separator (defensive — normal hosts have none).
if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) {
h
} else {
host_port
}
} else {
host_port
};
let host = host.trim().trim_end_matches('.'); // drop trailing root dot
if host.is_empty() {
return None;
}
Some(host.to_ascii_lowercase())
}
/// Compute the eTLD+1 (registrable domain) of a host or origin string.
///
/// `https://sub.login.example.co.uk` → `example.co.uk`.
/// `x.com` → `x.com`. Returns `None` for a bare public suffix (`co.uk`),
/// an IP address, `localhost`, or anything without a registrable domain.
pub fn etld_plus_one(host: &str) -> Option<String> {
let host = host_of(host)?;
// psl::domain_str returns the registrable domain (eTLD+1) using the
// embedded Public Suffix List, or None for bare suffixes / non-domains.
psl::domain_str(&host).map(|d| d.to_ascii_lowercase())
}
/// True when two origins/hosts share the same registrable domain (eTLD+1).
///
/// Fail-closed: if *either* side has no derivable eTLD+1, returns `false`.
pub fn same_etld_plus_one(a: &str, b: &str) -> bool {
match (etld_plus_one(a), etld_plus_one(b)) {
(Some(a1), Some(b1)) => a1 == b1,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- host_of: scheme / port / case / userinfo / path normalization ----
#[test]
fn host_of_strips_scheme_and_lowercases() {
assert_eq!(host_of("https://Sub.X.COM").as_deref(), Some("sub.x.com"));
assert_eq!(host_of("HTTP://EXAMPLE.com").as_deref(), Some("example.com"));
}
#[test]
fn host_of_strips_port() {
assert_eq!(host_of("https://login.x.com:8443").as_deref(), Some("login.x.com"));
assert_eq!(host_of("x.com:443").as_deref(), Some("x.com"));
}
#[test]
fn host_of_strips_path_query_fragment() {
assert_eq!(host_of("https://x.com/login?next=1#frag").as_deref(), Some("x.com"));
}
#[test]
fn host_of_strips_userinfo() {
assert_eq!(host_of("https://user:pass@x.com/path").as_deref(), Some("x.com"));
}
#[test]
fn host_of_handles_bare_host() {
assert_eq!(host_of("x.com").as_deref(), Some("x.com"));
}
#[test]
fn host_of_strips_trailing_dot() {
assert_eq!(host_of("https://x.com.").as_deref(), Some("x.com"));
}
#[test]
fn host_of_ipv6_literal_with_port() {
assert_eq!(host_of("http://[::1]:8080").as_deref(), Some("::1"));
// IPv6 has no eTLD+1, so domain binding fail-closes downstream.
assert_eq!(etld_plus_one("http://[::1]:8080"), None);
}
#[test]
fn host_of_empty_is_none() {
assert_eq!(host_of(""), None);
assert_eq!(host_of(" "), None);
assert_eq!(host_of("https://"), None);
}
// ---- etld_plus_one: real PSL behavior (the load-bearing correctness) ----
#[test]
fn etld_simple_two_label() {
assert_eq!(etld_plus_one("x.com").as_deref(), Some("x.com"));
}
#[test]
fn etld_collapses_subdomains() {
assert_eq!(etld_plus_one("sub.x.com").as_deref(), Some("x.com"));
assert_eq!(etld_plus_one("https://sub.login.x.com").as_deref(), Some("x.com"));
}
#[test]
fn etld_multilevel_suffix_co_uk_is_not_collapsed() {
// The critical PSL test: co.uk IS a public suffix, so the eTLD+1 of
// a.co.uk is a.co.uk — NOT co.uk. A naive last-two-labels heuristic
// would wrongly make a.co.uk and b.co.uk share an eTLD+1.
assert_eq!(etld_plus_one("a.co.uk").as_deref(), Some("a.co.uk"));
assert_eq!(etld_plus_one("b.co.uk").as_deref(), Some("b.co.uk"));
assert_ne!(etld_plus_one("a.co.uk"), etld_plus_one("b.co.uk"));
// Subdomains below the registrable domain still collapse correctly.
assert_eq!(etld_plus_one("www.a.co.uk").as_deref(), Some("a.co.uk"));
}
#[test]
fn etld_multilevel_suffix_com_cn() {
assert_eq!(etld_plus_one("shop.example.com.cn").as_deref(), Some("example.com.cn"));
assert_ne!(etld_plus_one("a.com.cn"), etld_plus_one("b.com.cn"));
}
#[test]
fn etld_bare_public_suffix_is_none() {
assert_eq!(etld_plus_one("co.uk"), None);
assert_eq!(etld_plus_one("com"), None);
}
#[test]
fn etld_is_lowercased() {
assert_eq!(etld_plus_one("SUB.X.COM").as_deref(), Some("x.com"));
}
// ---- same_etld_plus_one ----
#[test]
fn same_etld_subdomain_matches_parent() {
assert!(same_etld_plus_one("https://login.x.com", "x.com"));
assert!(same_etld_plus_one("https://sub.x.com:8443/path", "https://other.x.com"));
}
#[test]
fn same_etld_cross_domain_does_not_match() {
assert!(!same_etld_plus_one("x.com", "y.com"));
assert!(!same_etld_plus_one("https://evil.com", "x.com"));
}
#[test]
fn same_etld_distinct_co_uk_registrables_do_not_match() {
// Proves binding is on real eTLD+1, not the shared public suffix co.uk.
assert!(!same_etld_plus_one("a.co.uk", "b.co.uk"));
assert!(same_etld_plus_one("www.a.co.uk", "mail.a.co.uk"));
}
#[test]
fn same_etld_fail_closed_on_unparseable() {
assert!(!same_etld_plus_one("co.uk", "co.uk")); // bare suffix → None → false
assert!(!same_etld_plus_one("", "x.com"));
}
}
@@ -0,0 +1,29 @@
//! Error types for the secret vault.
/// Errors that can arise from secret registration and resolution.
///
/// Resolution is **fail-closed**: any failure to prove the current origin is
/// authorized yields [`None`] from [`crate::SecretStore::resolve`] rather than a
/// soft error, so a secret value is never returned on an untrusted origin.
/// `SecretError` is reserved for *registration*-time and *crypto*-time faults.
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
/// No secret is registered under the given name.
#[error("secret not found: no credential registered under that name")]
NotFound,
/// The current origin's eTLD+1 is not in the secret's allowed-origins set.
/// Resolution is denied (fail-closed); the value is never decrypted.
#[error("origin not allowed: current origin is not bound to this secret (fail-closed)")]
OriginNotAllowed,
/// A registered `allowed_origins` entry could not be parsed to an eTLD+1.
/// Registration is rejected so the binding can never silently match nothing.
#[error("invalid allowed origin '{0}': cannot derive a registrable domain (eTLD+1)")]
InvalidAllowedOrigin(String),
/// AES-GCM encryption/decryption (or key) failure. The message never
/// contains plaintext or key material.
#[error("crypto error: {0}")]
Crypto(String),
}
@@ -0,0 +1,495 @@
//! `nomifun-secret` — credential vault for the native browser-use engine.
//!
//! Provides a [`SecretStore`] that:
//!
//! * **encrypts** each registered value at rest with AES-256-GCM, reusing the
//! shared implementation in [`nomifun_common`] (`encrypt_string` /
//! `decrypt_string`) per P2 design ruling ⑦ — no second crypto stack;
//! * **binds** each secret to a set of allowed origins by their **eTLD+1**
//! (registrable domain), computed offline from the compile-time Public Suffix
//! List via the [`psl`] crate (DESIGN §4 / §16);
//! * **fail-closed resolves**: [`SecretStore::resolve`] decrypts and returns the
//! value *only* when the current origin's eTLD+1 is among the secret's allowed
//! eTLD+1s. Any failure to prove authorization (unknown name, unbound origin,
//! unparseable host) yields [`None`]. This holds regardless of session mode:
//! the gate is a property of the store, not of an orchestration approval that
//! yolo/companion could bypass.
//!
//! The returned [`SecretValue`] redacts itself in `Debug`/`Display`; plaintext
//! is reachable only via [`SecretValue::expose`] (for `Input.insertText`
//! injection) — the value must never reach the LLM, logs, or the ref table.
//!
//! ## Key provisioning
//!
//! The AES-256-GCM key is a 32-byte secret supplied by the caller via
//! [`SecretStore::new`], matching the codebase convention of threading
//! `encryption_key: [u8; 32]` (the machine-bound `encryption_key` file
//! provisioned at the app data-dir layer). This crate does **not** invent its
//! own machine-binding scheme. [`SecretStore::ephemeral`] generates a random
//! per-process key for tests and headless throwaway use.
mod domain;
mod error;
mod value;
mod vault;
// X2 `web` feature: per-pet secret CRUD service + axum routes (mounted by
// nomifun-app). Gated so pure-logic consumers don't pull axum / auth / api-types.
#[cfg(feature = "web")]
mod routes;
#[cfg(feature = "web")]
pub mod service;
#[cfg(feature = "web")]
mod state;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub use domain::{etld_plus_one, host_of, same_etld_plus_one};
pub use error::SecretError;
pub use value::SecretValue;
pub use vault::{
SHARED_SECRET_DIR, SecretVaultFile, load_secret_store, pet_vault_path, save_secret_store,
secret_vault_path, shared_vault_path,
};
#[cfg(feature = "web")]
pub use routes::secret_routes;
#[cfg(feature = "web")]
pub use service::SecretService;
#[cfg(feature = "web")]
pub use state::SecretRouterState;
/// Size of the AES-256-GCM key, in bytes.
pub const KEY_SIZE: usize = 32;
/// An encrypted secret bound to a set of registrable domains (eTLD+1).
///
/// **The `ciphertext` is already AES-256-GCM-encrypted** (the value never lives in
/// the clear in a `SecretRecord`). The record is `Serialize`/`Deserialize` so the
/// store can be **persisted as-is** (X2 vault: a JSON file of records, where every
/// `value` is already ciphertext — the on-disk file therefore never contains
/// plaintext, and we do **not** add a second crypto layer over the per-record AES).
/// `allowed_etld1` + `name` are not secret (they are policy, not credentials).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SecretRecord {
/// base64(nonce || ciphertext || tag), produced by `encrypt_string`.
ciphertext: String,
/// Allowed origins reduced to their eTLD+1 (already normalized/lowercased).
allowed_etld1: Vec<String>,
}
/// A secret's **non-sensitive metadata** for listing — its name and the set of
/// registrable domains (eTLD+1) it is bound to. **Never carries the value**
/// (X2 红线:列表绝不回 value / 不过 LLMvalue 仅经 [`SecretStore::resolve`] →
/// `Input.insertText` 注入)。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretListing {
/// The secret's name (the `secret:NAME` reference key).
pub name: String,
/// The registrable domains (eTLD+1) this secret is bound to.
pub allowed_etld1: Vec<String>,
}
/// AES-GCM-encrypted, origin-bound credential store.
///
/// Holds `name -> (ciphertext, allowed eTLD+1 set)`. The encryption key never
/// leaves the store; only [`resolve`](Self::resolve) decrypts, and only after
/// the origin gate passes.
pub struct SecretStore {
key: [u8; KEY_SIZE],
secrets: HashMap<String, SecretRecord>,
}
impl SecretStore {
/// Create a store backed by a caller-supplied 32-byte AES-256-GCM key.
///
/// The key should be the app's machine-bound `encryption_key` (the same one
/// threaded as `[u8; 32]` elsewhere in the backend).
pub fn new(key: [u8; KEY_SIZE]) -> Self {
SecretStore {
key,
secrets: HashMap::new(),
}
}
/// Create a store with a random, per-process ephemeral key.
///
/// Suitable for tests and headless throwaway sessions where persistence
/// across restarts is not required. Returns a [`SecretError::Crypto`] if the
/// system RNG fails.
pub fn ephemeral() -> Result<Self, SecretError> {
let mut key = [0u8; KEY_SIZE];
getrandom::getrandom(&mut key).map_err(|e| SecretError::Crypto(format!("RNG failure: {e}")))?;
Ok(SecretStore::new(key))
}
/// Register (or overwrite) a secret bound to `allowed_origins`.
///
/// Each `allowed_origins` entry may be a bare host (`x.com`) or a full
/// origin (`https://x.com:443`); it is reduced to its eTLD+1. An entry with
/// no derivable eTLD+1 (a bare public suffix, an IP, `localhost`, …) is
/// rejected with [`SecretError::InvalidAllowedOrigin`] so a binding can
/// never silently match nothing. An empty `allowed_origins` is likewise
/// rejected — an unbound secret could never resolve and is almost certainly
/// a caller error.
pub fn register(&mut self, name: &str, value: &str, allowed_origins: Vec<String>) -> Result<(), SecretError> {
if allowed_origins.is_empty() {
return Err(SecretError::InvalidAllowedOrigin(String::new()));
}
let mut allowed_etld1 = Vec::with_capacity(allowed_origins.len());
for origin in &allowed_origins {
let e1 = etld_plus_one(origin).ok_or_else(|| SecretError::InvalidAllowedOrigin(origin.clone()))?;
if !allowed_etld1.contains(&e1) {
allowed_etld1.push(e1);
}
}
let ciphertext =
nomifun_common::encrypt_string(value, &self.key).map_err(|e| SecretError::Crypto(e.to_string()))?;
self.secrets
.insert(name.to_string(), SecretRecord { ciphertext, allowed_etld1 });
Ok(())
}
/// Resolve a secret for the **current origin**, fail-closed.
///
/// Returns `Some(SecretValue)` only when:
/// 1. a secret is registered under `name`, **and**
/// 2. `current_origin`'s eTLD+1 is among the secret's allowed eTLD+1s, **and**
/// 3. decryption succeeds.
///
/// Any other case (unknown name, unbound origin, unparseable host, crypto
/// failure) returns `None`. The comparison ignores scheme/port/path and is
/// case-insensitive on the host (`host_of` normalization).
///
/// Returning `None` rather than a `Result` keeps the gate fail-closed by
/// type: there is no error path that could be mishandled into exposing a
/// value on an untrusted origin.
pub fn resolve(&self, name: &str, current_origin: &str) -> Option<SecretValue> {
let record = self.secrets.get(name)?;
let current_e1 = etld_plus_one(current_origin)?;
if !record.allowed_etld1.iter().any(|allowed| allowed == &current_e1) {
return None;
}
match nomifun_common::decrypt_string(&record.ciphertext, &self.key) {
Ok(plaintext) => Some(SecretValue::new(plaintext)),
Err(_) => None,
}
}
/// Number of registered secrets.
pub fn len(&self) -> usize {
self.secrets.len()
}
/// True when no secrets are registered.
pub fn is_empty(&self) -> bool {
self.secrets.is_empty()
}
/// Remove a secret. Returns `true` if it existed.
pub fn remove(&mut self, name: &str) -> bool {
self.secrets.remove(name).is_some()
}
/// List every secret's **non-sensitive metadata** (name + bound eTLD+1s),
/// sorted by name for a stable UI order. **Never exposes the value** (the
/// listing carries only policy, not credentials) — this is the type that
/// backs the `list_secrets` endpoint.
pub fn list(&self) -> Vec<SecretListing> {
let mut out: Vec<SecretListing> = self
.secrets
.iter()
.map(|(name, rec)| SecretListing {
name: name.clone(),
allowed_etld1: rec.allowed_etld1.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// The union of every registered secret's allowed eTLD+1s (deduped, sorted).
///
/// **裁决⑤ 共用真值**this is the data source for `FirewallConfig.allow_etld1`
/// — the same per-pet `allowed_origins` that gate `secret:NAME` resolution also
/// gate the browser's egress domain allowlist (one config, two uses). An empty
/// store yields an empty vec → the firewall's domain allowlist stays empty
/// (= unrestricted egress, current behavior) until the user registers a secret.
pub fn allowed_etld1_union(&self) -> Vec<String> {
let mut set: Vec<String> = Vec::new();
for rec in self.secrets.values() {
for e1 in &rec.allowed_etld1 {
if !set.contains(e1) {
set.push(e1.clone());
}
}
}
set.sort();
set
}
/// Export the store's records (name → already-encrypted record) for
/// persistence. The values stay ciphertext throughout — this never decrypts.
pub(crate) fn to_records(&self) -> HashMap<String, SecretRecord> {
self.secrets.clone()
}
/// Rebuild a store from persisted records under `key`. The records carry
/// ciphertext encrypted under the **same machine-bound key**; we do not
/// re-encrypt. A `resolve` later will GCM-authenticate against `key` (a
/// wrong key → `None`, fail-closed).
pub(crate) fn from_records(key: [u8; KEY_SIZE], secrets: HashMap<String, SecretRecord>) -> Self {
SecretStore { key, secrets }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> SecretStore {
SecretStore::new([0x42; KEY_SIZE])
}
// ---- registration ----
#[test]
fn register_rejects_empty_allowed_origins() {
let mut s = store();
assert!(matches!(
s.register("pw", "v", vec![]),
Err(SecretError::InvalidAllowedOrigin(_))
));
}
#[test]
fn register_rejects_bare_public_suffix() {
let mut s = store();
assert!(matches!(
s.register("pw", "v", vec!["co.uk".into()]),
Err(SecretError::InvalidAllowedOrigin(_))
));
}
#[test]
fn register_accepts_origin_with_scheme_and_port() {
let mut s = store();
assert!(s.register("pw", "v", vec!["https://x.com:443".into()]).is_ok());
}
// ---- resolve origin gate (fail-closed) ----
#[test]
fn resolve_allows_subdomain_of_allowed_origin() {
let mut s = store();
s.register("pw", "secret-val", vec!["x.com".into()]).unwrap();
let got = s.resolve("pw", "https://login.x.com");
assert_eq!(got.map(|v| v.into_inner()).as_deref(), Some("secret-val"));
}
#[test]
fn resolve_allows_deep_subdomain_with_port_and_path() {
let mut s = store();
s.register("pw", "secret-val", vec!["x.com".into()]).unwrap();
assert!(s.resolve("pw", "https://sub.login.x.com:8443/account?next=1").is_some());
}
#[test]
fn resolve_denies_cross_domain_fail_closed() {
let mut s = store();
s.register("pw", "secret-val", vec!["x.com".into()]).unwrap();
assert!(s.resolve("pw", "https://evil.com").is_none());
assert!(s.resolve("pw", "https://x.com.evil.com").is_none());
}
#[test]
fn resolve_denies_distinct_co_uk_registrable() {
// a.co.uk and b.co.uk are different registrable domains (co.uk is a
// public suffix). Binding to a.co.uk must NOT leak to b.co.uk.
let mut s = store();
s.register("pw", "secret-val", vec!["a.co.uk".into()]).unwrap();
assert!(s.resolve("pw", "https://www.a.co.uk").is_some());
assert!(s.resolve("pw", "https://b.co.uk").is_none());
}
#[test]
fn resolve_unknown_name_is_none() {
let s = store();
assert!(s.resolve("missing", "https://x.com").is_none());
}
#[test]
fn resolve_unparseable_origin_is_none() {
let mut s = store();
s.register("pw", "secret-val", vec!["x.com".into()]).unwrap();
assert!(s.resolve("pw", "").is_none());
assert!(s.resolve("pw", "co.uk").is_none()); // bare suffix → None
}
#[test]
fn resolve_ignores_scheme_and_case() {
let mut s = store();
s.register("pw", "secret-val", vec!["X.COM".into()]).unwrap();
assert!(s.resolve("pw", "HTTP://LOGIN.X.COM").is_some());
}
#[test]
fn resolve_multiple_allowed_origins() {
let mut s = store();
s.register("pw", "secret-val", vec!["x.com".into(), "y.org".into()])
.unwrap();
assert!(s.resolve("pw", "https://a.x.com").is_some());
assert!(s.resolve("pw", "https://b.y.org").is_some());
assert!(s.resolve("pw", "https://z.com").is_none());
}
// ---- AES round-trip & ciphertext != plaintext ----
#[test]
fn aes_round_trip_recovers_value() {
let mut s = store();
s.register("pw", "the-real-password", vec!["x.com".into()]).unwrap();
let v = s.resolve("pw", "https://x.com").unwrap();
assert_eq!(v.expose(), "the-real-password");
}
#[test]
fn ciphertext_differs_from_plaintext() {
let mut s = store();
let plain = "the-real-password";
s.register("pw", plain, vec!["x.com".into()]).unwrap();
let ct = &s.secrets.get("pw").unwrap().ciphertext;
assert_ne!(ct, plain);
assert!(!ct.contains(plain), "ciphertext must not contain plaintext");
}
#[test]
fn wrong_key_decryption_fails_closed_to_none() {
// Encrypt under one key, attempt resolve under a store with another key:
// GCM auth fails → resolve returns None (never a corrupt value).
let mut s1 = SecretStore::new([0x01; KEY_SIZE]);
s1.register("pw", "v", vec!["x.com".into()]).unwrap();
let record = s1.secrets.remove("pw").unwrap();
let mut s2 = SecretStore::new([0x02; KEY_SIZE]);
s2.secrets.insert("pw".into(), record);
assert!(s2.resolve("pw", "https://x.com").is_none());
}
// ---- bookkeeping ----
#[test]
fn len_and_remove() {
let mut s = store();
assert!(s.is_empty());
s.register("a", "1", vec!["x.com".into()]).unwrap();
s.register("b", "2", vec!["y.com".into()]).unwrap();
assert_eq!(s.len(), 2);
assert!(s.remove("a"));
assert!(!s.remove("a"));
assert_eq!(s.len(), 1);
}
#[test]
fn register_overwrites_same_name() {
let mut s = store();
s.register("pw", "old", vec!["x.com".into()]).unwrap();
s.register("pw", "new", vec!["x.com".into()]).unwrap();
assert_eq!(s.len(), 1);
assert_eq!(s.resolve("pw", "https://x.com").unwrap().expose(), "new");
}
#[test]
fn ephemeral_store_works() {
let mut s = SecretStore::ephemeral().unwrap();
s.register("pw", "v", vec!["x.com".into()]).unwrap();
assert_eq!(s.resolve("pw", "https://x.com").unwrap().expose(), "v");
}
// ---- list (metadata only, NEVER the value) ----
#[test]
fn list_returns_name_and_origins_never_value() {
let mut s = store();
s.register("github", "ghp_supersecret", vec!["github.com".into()]).unwrap();
s.register("bank", "hunter2", vec!["chase.com".into(), "https://www.chase.com".into()])
.unwrap();
let listed = s.list();
// Sorted by name → bank, github.
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].name, "bank");
assert_eq!(listed[0].allowed_etld1, vec!["chase.com".to_string()]); // both inputs → one eTLD+1
assert_eq!(listed[1].name, "github");
assert_eq!(listed[1].allowed_etld1, vec!["github.com".to_string()]);
// **安全断言**the listing type carries NO value field and its serialized
// form must never contain any plaintext value.
let json = serde_json::to_string(&listed).unwrap();
assert!(!json.contains("ghp_supersecret"), "list must NOT leak value: {json}");
assert!(!json.contains("hunter2"), "list must NOT leak value: {json}");
}
#[test]
fn list_empty_store_is_empty() {
assert!(store().list().is_empty());
}
// ---- allowed_etld1_union (裁决⑤ 共用真值: secret allowed_origins → firewall allow_etld1) ----
#[test]
fn allowed_etld1_union_dedups_and_sorts() {
let mut s = store();
s.register("a", "1", vec!["x.com".into(), "https://sub.y.org".into()]).unwrap();
s.register("b", "2", vec!["y.org".into(), "z.net".into()]).unwrap(); // y.org overlaps a's
let union = s.allowed_etld1_union();
// Deduped (y.org once) + sorted.
assert_eq!(union, vec!["x.com".to_string(), "y.org".to_string(), "z.net".to_string()]);
}
#[test]
fn allowed_etld1_union_empty_store_is_empty() {
// Empty store → empty allowlist → firewall stays unrestricted (zero regression).
assert!(store().allowed_etld1_union().is_empty());
}
// ---- to_records / from_records round-trip (vault persistence reuses ciphertext, no double crypto) ----
#[test]
fn records_round_trip_preserves_resolve_and_keeps_ciphertext() {
let mut s = SecretStore::new([0x42; KEY_SIZE]);
s.register("pw", "the-real-password", vec!["x.com".into()]).unwrap();
let records = s.to_records();
// Records carry ciphertext, NOT plaintext.
let recs_json = serde_json::to_string(&records).unwrap();
assert!(!recs_json.contains("the-real-password"), "records must hold ciphertext only");
// Rebuild under the SAME key → resolve still works (no re-encryption).
let rebuilt = SecretStore::from_records([0x42; KEY_SIZE], records.clone());
assert_eq!(rebuilt.resolve("pw", "https://login.x.com").unwrap().expose(), "the-real-password");
// Rebuild under a DIFFERENT key → GCM auth fails → resolve None (fail-closed).
let wrong = SecretStore::from_records([0x99; KEY_SIZE], records);
assert!(wrong.resolve("pw", "https://x.com").is_none(), "wrong key must fail-closed");
}
// ---- nonce randomness (inherited from nomifun-common, asserted here) ----
#[test]
fn same_value_yields_different_ciphertext() {
let mut s = store();
s.register("a", "same", vec!["x.com".into()]).unwrap();
s.register("b", "same", vec!["x.com".into()]).unwrap();
let ca = &s.secrets.get("a").unwrap().ciphertext;
let cb = &s.secrets.get("b").unwrap().ciphertext;
assert_ne!(ca, cb, "random nonce must produce distinct ciphertexts");
}
}
@@ -0,0 +1,165 @@
//! `/api/browser-secrets/*` route handlers (P3-X2).
//!
//! Per-pet browser-use credential CRUD. Handlers do request/response transformation
//! only; all logic lives in [`SecretService`](crate::service::SecretService). Auth is
//! layered externally in nomifun-app (mirrors the knowledge / webhook routes).
//!
//! **安全红线**the secret *value* is write-only — accepted on `POST` (register) and
//! then encrypted into the per-pet vault. **No endpoint ever returns it.** `GET`
//! (list) returns only name + bound origins (the [`SecretListItem`] metadata).
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, State};
use axum::http::StatusCode;
use axum::routing::{delete, get};
use nomifun_api_types::{ApiResponse, RegisterSecretRequest, SecretListItem};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use crate::state::SecretRouterState;
pub fn secret_routes(state: SecretRouterState) -> Router {
Router::new()
// List (metadata only — NEVER the value) + register for a pet.
.route("/api/browser-secrets/{pet_id}", get(list_secrets).post(register_secret))
// Remove a single secret by name.
.route("/api/browser-secrets/{pet_id}/{name}", delete(remove_secret))
.with_state(state)
}
async fn list_secrets(
State(state): State<SecretRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(pet_id): Path<String>,
) -> Result<Json<ApiResponse<Vec<SecretListItem>>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.list(&pet_id))))
}
async fn register_secret(
State(state): State<SecretRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(pet_id): Path<String>,
body: Result<Json<RegisterSecretRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<()>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.service
.register(&pet_id, &req.name, &req.value, req.allowed_origins)?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(()))))
}
async fn remove_secret(
State(state): State<SecretRouterState>,
Extension(_user): Extension<CurrentUser>,
Path((pet_id, name)): Path<(String, String)>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.remove(&pet_id, &name)?;
Ok(Json(ApiResponse::ok(())))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::KEY_SIZE;
use crate::service::SecretService;
use axum::body::Body;
use axum::http::Request;
use nomifun_auth::CurrentUser;
use tower::ServiceExt;
fn router_with_user(dir: &std::path::Path) -> Router {
let svc = SecretService::new(dir.to_path_buf(), [0x42; KEY_SIZE]);
secret_routes(SecretRouterState::new(svc))
// Inject a CurrentUser directly (the real auth middleware is layered in
// nomifun-app; tests attach the extension so the Extension extractor resolves).
.layer(axum::Extension(CurrentUser {
id: "u1".into(),
username: "tester".into(),
}))
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn register_then_list_never_returns_value() {
let dir = tempfile::tempdir().unwrap();
let app = router_with_user(dir.path());
// Register.
let reg = Request::builder()
.method("POST")
.uri("/api/browser-secrets/pet-1")
.header("content-type", "application/json")
.body(Body::from(
r#"{"name":"github","value":"ghp_supersecret","allowed_origins":["github.com"]}"#,
))
.unwrap();
let resp = app.clone().oneshot(reg).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
// List — must carry name + origins, NEVER the value.
let list = Request::builder()
.method("GET")
.uri("/api/browser-secrets/pet-1")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let text = body_string(resp).await;
assert!(text.contains("github"), "list should carry the name: {text}");
assert!(text.contains("github.com"), "list should carry the bound origin: {text}");
assert!(!text.contains("ghp_supersecret"), "list MUST NOT leak the value: {text}");
}
#[tokio::test]
async fn register_rejects_bad_origin_with_400() {
let dir = tempfile::tempdir().unwrap();
let app = router_with_user(dir.path());
let reg = Request::builder()
.method("POST")
.uri("/api/browser-secrets/pet-1")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"n","value":"v","allowed_origins":["co.uk"]}"#))
.unwrap();
let resp = app.oneshot(reg).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn remove_then_list_empty() {
let dir = tempfile::tempdir().unwrap();
let app = router_with_user(dir.path());
let reg = Request::builder()
.method("POST")
.uri("/api/browser-secrets/pet-1")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"pw","value":"v","allowed_origins":["x.com"]}"#))
.unwrap();
app.clone().oneshot(reg).await.unwrap();
let del = Request::builder()
.method("DELETE")
.uri("/api/browser-secrets/pet-1/pw")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let list = Request::builder()
.method("GET")
.uri("/api/browser-secrets/pet-1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(list).await.unwrap();
let text = body_string(resp).await;
assert!(!text.contains("\"pw\""), "removed secret must be gone: {text}");
}
}
@@ -0,0 +1,202 @@
//! **P3-X2secret 注册服务**vault 管理 + 端点逻辑)。
//!
//! 把 [`crate::vault`] 的纯 save/load 包成一个 CRUD 服务,供 `/api/browser-secrets/*` 端点
//! [`crate::routes`])调用。职责:
//!
//! - 解析 vault 路径:用户决策(去 per-pet 键化)→ 浏览器身份**全局共享**,所有 pet_id 归一到**同一份**
//! 共享 vault `{data_dir}/browser-secrets/shared/secrets.json`(与「多宠物统一记忆」一致;pet_id 形参
//! 保留以兼容端点 URL,内部忽略)。
//! - `register`load 现有 store → `register(name,value,allowed_origins)` → save 回盘(value 加密落
//! vault**绝不**回前端/LLM)。
//! - `list`load → 返 [`SecretListItem`]**仅** name + allowed_origins**绝无** value)。
//! - `remove`load → `remove(name)` → save。
//!
//! 服务持机器绑定 `encryption_key`app data-dir 层 provision 的同一把 `[u8; 32]`);每次操作 load→改→
//! save(无内存缓存——secret 操作低频,避免与会话侧 [`crate::SecretStore`] 的缓存不一致)。
use std::path::PathBuf;
use nomifun_api_types::SecretListItem;
use nomifun_common::AppError;
use crate::vault::{load_secret_store, pet_vault_path, save_secret_store};
use crate::{KEY_SIZE, SecretError};
/// per-pet 浏览器凭据 secret 服务。Clone-cheap(仅 PathBuf + key 拷贝)。
#[derive(Clone)]
pub struct SecretService {
/// app 数据目录(共享 vault 挂在 `{data_dir}/browser-secrets/shared` 下)。
data_dir: PathBuf,
/// 机器绑定 AES-256-GCM keyapp `encryption_key`,全后端同一把)。
key: [u8; KEY_SIZE],
}
impl SecretService {
/// 用 app 数据目录 + 机器绑定 key 构造。`key` 必须是 app data-dir 层 provision 的
/// `encryption_key``derive_encryption_key`),与会话侧注入 [`crate::SecretStore`] 同一把,
/// 否则注册的 secret 在会话里 GCM 认证失败(resolve None)。
pub fn new(data_dir: PathBuf, key: [u8; KEY_SIZE]) -> Self {
Self { data_dir, key }
}
/// 解析 vault 路径(委托共享 [`pet_vault_path`]——去 per-pet 键化后恒归一到共享单例
/// `{data_dir}/browser-secrets/shared/secrets.json`,与会话侧 agent factory 构造 `BrowserSecretSource`
/// 用**同一**份,故任一伙伴注册落盘与任何会话加载命中同一文件,凭据跨伙伴共享)。`pet_id` 形参保留
/// 以兼容端点 URL,内部被忽略。
pub fn vault_path_for(&self, pet_id: &str) -> PathBuf {
pet_vault_path(&self.data_dir, pet_id)
}
/// 注册(或覆盖)一个 secret。value 加密落 vault**绝不**回前端/LLM)。`allowed_origins` 空 / 无
/// 可解析 eTLD+1 → [`AppError::BadRequest`](绑定不可能匹配任何域,几乎必是调用方错误)。
pub fn register(&self, pet_id: &str, name: &str, value: &str, allowed_origins: Vec<String>) -> Result<(), AppError> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("secret name must not be empty".into()));
}
if value.is_empty() {
return Err(AppError::BadRequest("secret value must not be empty".into()));
}
let path = self.vault_path_for(pet_id);
let mut store = load_secret_store(&path, self.key);
store.register(name, value, allowed_origins).map_err(map_secret_err)?;
save_secret_store(&store, &path)
.map_err(|e| AppError::Internal(format!("persist secret vault failed: {e}")))?;
Ok(())
}
/// 列出某 pet 已注册 secret 的**元数据**name + allowed_origins**绝无 value**)。
pub fn list(&self, pet_id: &str) -> Vec<SecretListItem> {
let store = load_secret_store(&self.vault_path_for(pet_id), self.key);
store
.list()
.into_iter()
.map(|l| SecretListItem {
name: l.name,
allowed_origins: l.allowed_etld1,
})
.collect()
}
/// 删除一个 secret。`Ok(true)` 删了、`Ok(false)` 本就不存在(幂等)。
pub fn remove(&self, pet_id: &str, name: &str) -> Result<bool, AppError> {
let path = self.vault_path_for(pet_id);
let mut store = load_secret_store(&path, self.key);
let removed = store.remove(name.trim());
if removed {
save_secret_store(&store, &path)
.map_err(|e| AppError::Internal(format!("persist secret vault failed: {e}")))?;
}
Ok(removed)
}
}
/// `SecretError` → `AppError`(注册期的策略/crypto 错;resolve 期是 fail-closed 的 `None`,不走这)。
fn map_secret_err(e: SecretError) -> AppError {
match e {
SecretError::InvalidAllowedOrigin(o) => AppError::BadRequest(format!(
"invalid allowed origin '{o}': must be a host/origin with a registrable domain (eTLD+1)"
)),
SecretError::Crypto(m) => AppError::Internal(format!("secret crypto error: {m}")),
SecretError::NotFound => AppError::NotFound("secret not found".into()),
SecretError::OriginNotAllowed => {
AppError::BadRequest("origin not allowed for this secret".into())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn service() -> (tempfile::TempDir, SecretService) {
let dir = tempfile::tempdir().expect("tempdir");
let svc = SecretService::new(dir.path().to_path_buf(), [0x42; KEY_SIZE]);
(dir, svc)
}
#[test]
fn register_then_list_excludes_value() {
let (_d, svc) = service();
svc.register("pet-1", "github", "ghp_supersecret", vec!["github.com".into()]).unwrap();
let listed = svc.list("pet-1");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "github");
assert_eq!(listed[0].allowed_origins, vec!["github.com".to_string()]);
// **安全断言**the listing's serialized form must never contain the value.
let json = serde_json::to_string(&listed).unwrap();
assert!(!json.contains("ghp_supersecret"), "list must NOT leak value: {json}");
}
#[test]
fn register_persists_across_service_instances() {
// 模拟「注册(端点) → 新会话 load」:同 data_dir + key 的新服务实例看得见已注册的 secret。
let dir = tempfile::tempdir().expect("tempdir");
let s1 = SecretService::new(dir.path().to_path_buf(), [0x42; KEY_SIZE]);
s1.register("pet-1", "pw", "secret-val", vec!["x.com".into()]).unwrap();
let s2 = SecretService::new(dir.path().to_path_buf(), [0x42; KEY_SIZE]);
let listed = s2.list("pet-1");
assert_eq!(listed.len(), 1, "registered secret must persist to disk across instances");
// 会话侧的 SecretStore 从同一 vault 加载后,resolve 应取到真值(origin 门)。
let store = load_secret_store(&s2.vault_path_for("pet-1"), [0x42; KEY_SIZE]);
assert_eq!(store.resolve("pw", "https://x.com").unwrap().expose(), "secret-val");
}
#[test]
fn all_pets_share_one_vault() {
// 用户决策(去 per-pet 键化):浏览器身份全局共享——不同 pet_id 注册的 secret 互见(同一份
// 共享 vault)。推翻旧版「per-pet 隔离不串」断言。
let (_d, svc) = service();
svc.register("pet-1", "github", "ghp_a", vec!["x.com".into()]).unwrap();
svc.register("pet-2", "stripe", "sk_b", vec!["y.com".into()]).unwrap();
// 任一 pet_id 列出都看得见**全部**已注册 secret(共享单例)。
let l1 = svc.list("pet-1");
let l2 = svc.list("pet-2");
assert_eq!(l1.len(), 2, "pet-1 must see both secrets (shared vault): {l1:?}");
assert_eq!(l2.len(), 2, "pet-2 must see both secrets (shared vault): {l2:?}");
// 甚至空/陌生 pet_id 也看见同一份。
assert_eq!(svc.list("").len(), 2, "empty pet_id routes to the same shared vault");
}
#[test]
fn remove_is_idempotent() {
let (_d, svc) = service();
svc.register("pet-1", "pw", "v", vec!["x.com".into()]).unwrap();
assert!(svc.remove("pet-1", "pw").unwrap(), "first remove deletes");
assert!(!svc.remove("pet-1", "pw").unwrap(), "second remove is a no-op (idempotent)");
assert!(svc.list("pet-1").is_empty());
}
#[test]
fn register_rejects_empty_value_and_name() {
let (_d, svc) = service();
assert!(matches!(svc.register("p", "", "v", vec!["x.com".into()]), Err(AppError::BadRequest(_))));
assert!(matches!(svc.register("p", "n", "", vec!["x.com".into()]), Err(AppError::BadRequest(_))));
}
#[test]
fn register_rejects_unparseable_origin() {
let (_d, svc) = service();
// bare public suffix → no eTLD+1 → 400.
assert!(matches!(
svc.register("p", "n", "v", vec!["co.uk".into()]),
Err(AppError::BadRequest(_))
));
// empty allowed_origins → 400.
assert!(matches!(svc.register("p", "n", "v", vec![]), Err(AppError::BadRequest(_))));
}
#[test]
fn any_pet_id_routes_to_the_shared_vault() {
// 用户决策(去 per-pet 键化):任意 pet_id(空 / 含分隔符 / companion / conversation)都解析到
// **同一份**共享 vault `{data_dir}/browser-secrets/shared/secrets.json`。
let (_d, svc) = service();
let tail = std::path::Path::new("browser-secrets").join("shared").join("secrets.json");
for id in ["", "../../etc", "conversation:5", "pet-x"] {
assert!(svc.vault_path_for(id).ends_with(&tail), "pet_id {id:?} must route to shared vault");
}
// 不同 id 解析同一路径(共享单例硬证据)。
assert_eq!(svc.vault_path_for("a"), svc.vault_path_for("b"));
}
}
@@ -0,0 +1,15 @@
//! Router state for the browser-secret endpoints (P3-X2).
use crate::service::SecretService;
/// Router state for `/api/browser-secrets/*`.
#[derive(Clone)]
pub struct SecretRouterState {
pub service: SecretService,
}
impl SecretRouterState {
pub fn new(service: SecretService) -> Self {
Self { service }
}
}
@@ -0,0 +1,83 @@
//! Decrypted secret value with redacted formatting.
use std::fmt;
/// A decrypted secret value.
///
/// **Redacted by construction**: both [`Debug`] and [`Display`] render
/// `<redacted>` and never the plaintext, mirroring `TypeInput::Secret` in the
/// browser engine. The plaintext is reachable *only* via [`SecretValue::expose`]
/// — callers that expose it (e.g. `Input.insertText` injection) are responsible
/// for ensuring it never reaches the LLM, logs, or the ref table (DESIGN §16).
#[derive(Clone, PartialEq, Eq)]
pub struct SecretValue(String);
impl SecretValue {
/// Wrap a plaintext value. Prefer obtaining values through
/// [`crate::SecretStore::resolve`], which enforces the origin gate.
pub fn new(plaintext: impl Into<String>) -> Self {
SecretValue(plaintext.into())
}
/// Return the plaintext. The only path to the secret material.
///
/// The caller assumes the obligation to keep it off the LLM / logs / refs.
pub fn expose(&self) -> &str {
&self.0
}
/// Consume and return the owned plaintext.
pub fn into_inner(self) -> String {
self.0
}
}
impl fmt::Debug for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretValue(<redacted>)")
}
}
impl fmt::Display for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLAIN: &str = "hunter2-super-secret-password";
#[test]
fn expose_returns_plaintext() {
let v = SecretValue::new(PLAIN);
assert_eq!(v.expose(), PLAIN);
assert_eq!(v.into_inner(), PLAIN);
}
#[test]
fn debug_is_redacted() {
let v = SecretValue::new(PLAIN);
let s = format!("{v:?}");
assert!(!s.contains(PLAIN), "Debug leaked plaintext: {s}");
assert!(s.contains("<redacted>"));
}
#[test]
fn display_is_redacted() {
let v = SecretValue::new(PLAIN);
let s = format!("{v}");
assert!(!s.contains(PLAIN), "Display leaked plaintext: {s}");
assert_eq!(s, "<redacted>");
}
#[test]
fn debug_redacted_even_when_nested() {
// e.g. format!("{:?}", Some(secret)) or in a struct must not leak.
let v = Some(SecretValue::new(PLAIN));
let s = format!("{v:?}");
assert!(!s.contains(PLAIN), "nested Debug leaked plaintext: {s}");
}
}
@@ -0,0 +1,322 @@
//! **P3-X2secret vault 落盘持久化(per-pet**(裁决⑦ / 收编 P2 X2)。
//!
//! E1 的 [`SecretStore`](crate::SecretStore) 是**纯内存** `HashMap`——注册的凭据进程退出即丢。X2 在
//! 其上塞一层**磁盘 vault**,使注册的 secret **跨会话/重启**仍可用(`secret:NAME` 不再因空 store 恒
//! fail-closed):
//!
//! ```text
//! 注册:register_secret 端点 → SecretStore.register → save_secret_store(vault) [本模块]
//! ↓ 磁盘
//! 用:会话起 → load_secret_store(vault, key) [本模块] → 注入 BrowserTool
//! → secret:NAME 经 origin 门解析
//! ```
//!
//! ## 为什么**不**再加一层加密(已是密文)
//! [`SecretStore`](crate::SecretStore) 的每条记录 `value` **本就是 AES-256-GCM 密文**
//! [`register`](crate::SecretStore::register) 调 [`nomifun_common::encrypt_string`] 用机器绑定 key 加密)。
//! 故 vault 文件直接落「记录数组」JSON 即可——**文件内绝无明文**(值是密文,`name`/`allowed_etld1` 是
//! 策略非凭据)。再套一层文件级 AES 是**双重加密**、无安全增益、只增复杂度,故**不做**(裁决⑦:单一
//! AES 栈,per-record 那一层即是)。机器绑定 keyapp data-dir 层 `encryption_key`,全后端 `[u8; 32]`
//! 一路穿透的同一把)→ vault 文件即便被拷走,换台机器 [`load_secret_store`] 后 `resolve` 的 GCM 认证
//! 仍失败 → fail-closed 返 `None`(永不泄值)。
//!
//! ## 浏览器身份全局共享(用户决策:去 per-pet 隔离)
//! vault 落**单一共享** [`SHARED_SECRET_DIR`] 子目录(`{data_dir}/browser-secrets/shared/secrets.json`
//! ——所有桌面伙伴 + 会话用**同一份**凭据保险库(与「多宠物统一记忆」一致),任一伙伴注册的 secret 在
//! 任何会话/伙伴里共享可见。[`pet_vault_path`] 保留 `pet_id` 形参以兼容调用方签名,但**内部忽略**它恒
//! 归一到 [`shared_vault_path`]。(历史 per-pet 隔离布局已退役;W4 引擎层 per-pet context 机制与此 vault
//! 键无关,仍保留休眠。)
//!
//! ## 优雅降级(绝不 panic
//! [`load_secret_store`] 对**任何**读取/解析失败都返**空 store**vault 不存在 = 首次、JSON 损坏 = 部分
//! 写入/坏块、形态变了)——secret 是**增强**,vault 坏了应静默退回「无注册凭据」起点(`secret:NAME`
//! 恒 fail-closed,用户重新注册即可),绝不让一个坏 vault 文件阻断引擎启动。密文损坏/换 key 不在此处
//! 暴露——它们在 `resolve` 时 GCM 认证失败成 `None`fail-closed),同样不致命。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{KEY_SIZE, SecretRecord, SecretStore};
/// secret vault 文件名。落「已加密记录」的 JSON(值是 per-record AES 密文,故文件本身**不**含明文
/// ——见模块 doc「为什么不再加密」)。
pub const SECRET_VAULT_FILE: &str = "secrets.json";
/// secret vault 根目录名(在 `data_dir` 下)。去 per-pet 键化后:`{data_dir}/browser-secrets/shared/
/// secrets.json`(单一共享 vault)。
pub const SECRETS_ROOT: &str = "browser-secrets";
/// vault 文件的 on-disk 形态(versionedforward-compat)。`secrets` 的每个 value 的 `ciphertext`
/// 本就是 AES 密文 → 文件无明文。
#[derive(Debug, Serialize, Deserialize)]
pub struct SecretVaultFile {
/// 形态版本(当前 1)。未来若改格式可据此迁移;不认得的版本 → [`load_secret_store`] 退回空 store。
pub version: u32,
/// name → 已加密记录(ciphertext + allowed_etld1)。
pub secrets: HashMap<String, SecretRecord>,
}
const VAULT_VERSION: u32 = 1;
/// 解析某目录下的 secret vault 路径 `<dir>/secrets.json`(纯 join,无 I/O)。被 [`shared_vault_path`]
/// 用于在 `{data_dir}/browser-secrets/shared` 下拼出 vault 文件名。
pub fn secret_vault_path(dir: &Path) -> PathBuf {
dir.join(SECRET_VAULT_FILE)
}
/// 共享 secret vault 的子目录名(在 `browser-secrets` 根下)。用户决策:浏览器身份**全局共享**——所有
/// 桌面伙伴 + 会话用**同一份**凭据保险库(与「多宠物统一记忆」一致),不再 per-pet 隔离。落
/// `{data_dir}/browser-secrets/shared/secrets.json`。
pub const SHARED_SECRET_DIR: &str = "shared";
/// **单一权威:解析共享 secret vault 的完整路径** `{data_dir}/browser-secrets/shared/secrets.json`。
///
/// 用户决策(去 per-pet 键化):浏览器身份全局共享——**所有伙伴/会话注册与解析走同一份 vault**,凭据
/// 跨伙伴互见。这是 [`pet_vault_path`] 内部归一到的目标路径。
///
/// **两端必须共用此份**:端点侧(`SecretService`,注册落盘)与会话侧(agent factory 构造
/// `BrowserSecretSource`,加载)+ 网关 registry——全部命中同一文件,故任一伙伴注册的 secret 在任何会话/
/// 伙伴里都看得见。
pub fn shared_vault_path(data_dir: &Path) -> PathBuf {
secret_vault_path(&data_dir.join(SECRETS_ROOT).join(SHARED_SECRET_DIR))
}
/// **解析 secret vault 路径**——历史上 per-pet 键化(`pet_id` 段),现归一到[共享单例](shared_vault_path)。
///
/// 用户决策:浏览器身份全局共享——`pet_id` 形参**保留以兼容现有调用方签名**(端点 URL/factory key/网关
/// key 仍照传),但**内部忽略**它,恒路由到 [`shared_vault_path`]`{data_dir}/browser-secrets/shared/
/// secrets.json`)。故任一伙伴注册的 secret 在所有会话/伙伴里共享可见——这是「共享」的落点。
///
/// (per-pet 隔离的目录布局已退役;W4a 引擎层 per-pet context 机制仍保留休眠,与此 vault 键无关。)
pub fn pet_vault_path(data_dir: &Path, _pet_id: &str) -> PathBuf {
// 用户决策:去 per-pet 键化,所有调用方归一到共享单例(pet_id 被忽略,仅保签名兼容)。
shared_vault_path(data_dir)
}
/// **把 [`SecretStore`] 持久化到磁盘 vault**(注册/删除后的「存」侧)。
///
/// 序列化 store 的(已加密)记录 → JSON → 写 `vault_path`。父目录不存在则 best-effort 建(per-pet 目录
/// 可能首次落 vault)。失败 → `Err`**绝不 panic**);调用方(端点)把它转成 5xx 即可。**绝不解密**——
/// 记录全程是密文。
pub fn save_secret_store(store: &SecretStore, vault_path: &Path) -> std::io::Result<()> {
let file = SecretVaultFile {
version: VAULT_VERSION,
secrets: store.to_records(),
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = vault_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(vault_path, json.as_bytes())
}
/// **从磁盘 vault 读出 [`SecretStore`]**(「取」侧),绑机器绑定 `key`。
///
/// 读 `vault_path` → 解析 JSON 记录 → [`SecretStore::from_records`]**不解密**,记录是密文;真正的
/// 解密 + origin 门发生在后续 `resolve`,换机/换 key → GCM 认证失败 → `None`fail-closed)。
///
/// **任何失败都返「空 store」(绝不 panic / 绝不 `Err`**——secret 是增强:
/// - vault 不存在(首次注册前)→ 空 store(连 warn 都不必);
/// - 读文件 I/O 失败 → 空 storewarn 留痕);
/// - JSON 损坏 / 版本不认得(部分写入/坏块/旧格式)→ 空 store(warn 留痕)。
///
/// 这把「坏 vault 阻断启动」彻底消除:最坏情况是丢注册凭据(`secret:NAME` fail-closed),不是崩。
pub fn load_secret_store(vault_path: &Path, key: [u8; KEY_SIZE]) -> SecretStore {
let json = match std::fs::read_to_string(vault_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SecretStore::new(key),
Err(e) => {
tracing::warn!(
target: "nomifun_secret::vault",
error = %e, path = %vault_path.display(),
"read secret vault failed; starting with an empty store (no persisted credentials)"
);
return SecretStore::new(key);
}
};
let file: SecretVaultFile = match serde_json::from_str(&json) {
Ok(f) => f,
Err(e) => {
tracing::warn!(
target: "nomifun_secret::vault",
error = %e, path = %vault_path.display(),
"parse secret vault JSON failed (corrupt or old format); starting with an empty store"
);
return SecretStore::new(key);
}
};
if file.version != VAULT_VERSION {
tracing::warn!(
target: "nomifun_secret::vault",
found = file.version, expected = VAULT_VERSION, path = %vault_path.display(),
"secret vault version mismatch; starting with an empty store"
);
return SecretStore::new(key);
}
SecretStore::from_records(key, file.secrets)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: [u8; KEY_SIZE] = [0x42; KEY_SIZE];
#[test]
fn secret_vault_path_joins_filename() {
// secret_vault_path 是纯 join<dir>/secrets.json(被 shared_vault_path 用于拼共享 vault)。
let dir = Path::new("/data/browser-secrets/shared");
let p = secret_vault_path(dir);
assert_eq!(p, Path::new("/data/browser-secrets/shared/secrets.json"));
assert!(p.starts_with(dir), "vault file must live under the given dir");
}
#[test]
fn pet_vault_path_routes_to_shared_singleton() {
// 用户决策(去 per-pet 键化):任意 pet_id 都归一到**同一份**共享 vault
// `{data_dir}/browser-secrets/shared/secrets.json`——浏览器身份全局共享。
let data = Path::new("/data");
let shared_tail = Path::new("browser-secrets").join("shared").join("secrets.json");
// 任意 pet_idcompanion / conversation / 空 / 含分隔符)都落同一份共享 vault。
for id in ["companion-1", "conversation:5", " ", "../../etc", ""] {
let p = pet_vault_path(data, id);
assert!(p.ends_with(&shared_tail), "pet_id {id:?} must route to the shared vault, got {p:?}");
}
// 共享单例:不同「pet」解析出**同一**路径(这是「共享」的硬证据,对比旧版 per-pet 互异)。
assert_eq!(pet_vault_path(data, "pet-a"), pet_vault_path(data, "pet-b"));
assert_eq!(pet_vault_path(data, "pet-a"), shared_vault_path(data));
}
#[test]
fn multiple_pets_share_one_store_credentials_visible_across() {
// **共享证据(纯逻辑)**:伙伴 A 在「自己的」pet_id 下注册 secret → 伙伴 B 用「另一个」pet_id
// 解析时(同 data_dir)命中同一共享 vault → 看得见 A 注册的凭据(凭据跨伙伴共享)。
let dir = tempfile::tempdir().expect("tempdir");
let data = dir.path();
// 伙伴 A 注册(端点侧落盘到「companion-A」键——内部归一到共享)。
let path_a = pet_vault_path(data, "companion-A");
let mut store_a = load_secret_store(&path_a, KEY);
store_a.register("pw", "shared-login-secret", vec!["x.com".into()]).unwrap();
save_secret_store(&store_a, &path_a).expect("save A");
// 伙伴 B(不同 pet_id)加载 → 看得见 A 的凭据(共享单例,互见)。
let path_b = pet_vault_path(data, "companion-B");
assert_eq!(path_a, path_b, "去 per-pet 键化:两伙伴解析同一共享 vault 文件");
let store_b = load_secret_store(&path_b, KEY);
assert_eq!(
store_b.resolve("pw", "https://x.com").unwrap().expose(),
"shared-login-secret",
"credential registered by A must be visible to B (shared identity)"
);
// 安全不变:origin 门仍 fail-closed(非绑定域 → None)。
assert!(store_b.resolve("pw", "https://evil.com").is_none(), "origin gate still fail-closed");
}
#[test]
fn save_then_load_round_trips_and_resolves() {
// **核心:注册 → 落盘 → 重载 → resolve 往返**(机器绑定 key 加密往返,跨「会话」保真)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
let mut store = SecretStore::new(KEY);
store.register("pw", "the-real-password", vec!["x.com".into()]).unwrap();
store.register("token", "ghp_abc", vec!["github.com".into(), "https://api.github.com".into()]).unwrap();
save_secret_store(&store, &path).expect("save");
// 新 store(模拟新会话/重启)从 vault 重载 —— resolve 仍按 origin 门解析。
let reloaded = load_secret_store(&path, KEY);
assert_eq!(reloaded.resolve("pw", "https://login.x.com").unwrap().expose(), "the-real-password");
assert_eq!(reloaded.resolve("token", "https://api.github.com").unwrap().expose(), "ghp_abc");
// origin 门仍 fail-closed(非绑定域 → None)。
assert!(reloaded.resolve("pw", "https://evil.com").is_none());
// list 不含 value(重载后元数据保真)。
assert_eq!(reloaded.list().len(), 2);
}
#[test]
fn vault_file_is_ciphertext_not_plaintext() {
// **加密验收:落盘内容不含明文 value**(值是 per-record AES 密文;文件无明文)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
let mut store = SecretStore::new(KEY);
store.register("pw", "deadbeef-secret-token", vec!["x.com".into()]).unwrap();
save_secret_store(&store, &path).expect("save");
let on_disk = std::fs::read_to_string(&path).expect("read raw vault");
assert!(!on_disk.contains("deadbeef-secret-token"), "value must NOT be plaintext on disk: {on_disk}");
// name / allowed_etld1 是策略(非凭据),可明文(用于 list/firewall);这里只断言 value 不明文。
assert!(on_disk.contains("x.com"), "policy (allowed_etld1) may be plaintext on disk");
}
#[test]
fn load_with_wrong_key_resolves_to_none_fail_closed() {
// 换机/换 key → 重载本身不报错(记录是密文,from_records 不解密),但 resolve 的 GCM 认证失败
// → Nonefail-closedvault 拷走也解不开)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
let mut store = SecretStore::new([0x01; KEY_SIZE]);
store.register("pw", "v", vec!["x.com".into()]).unwrap();
save_secret_store(&store, &path).expect("save");
let wrong = load_secret_store(&path, [0x02; KEY_SIZE]);
assert!(wrong.resolve("pw", "https://x.com").is_none(), "wrong key must fail-closed to None");
// 对的 key 仍解得回(证明只是 key 不对,非 vault 坏)。
let right = load_secret_store(&path, [0x01; KEY_SIZE]);
assert_eq!(right.resolve("pw", "https://x.com").unwrap().expose(), "v");
}
#[test]
fn load_missing_vault_is_empty_store_not_panic() {
// 优雅:vault 不存在(首次注册前)→ 空 store(绝不 panic)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path()); // 没 save 过
let s = load_secret_store(&path, KEY);
assert!(s.is_empty(), "missing vault must load to an empty store");
}
#[test]
fn load_corrupt_json_is_empty_store_not_panic() {
// 优雅:JSON 损坏(部分写入/坏块)→ 空 store(绝不 panic)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
std::fs::write(&path, "{not valid json!!!").expect("write garbage");
assert!(load_secret_store(&path, KEY).is_empty(), "corrupt vault must degrade to empty store");
}
#[test]
fn load_unknown_version_is_empty_store() {
// 优雅:版本不认得(旧/未来格式)→ 空 store(绝不 panic)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
std::fs::write(&path, r#"{"version":999,"secrets":{}}"#).expect("write");
assert!(load_secret_store(&path, KEY).is_empty(), "unknown version must degrade to empty store");
}
#[test]
fn save_creates_parent_dir() {
// per-pet 目录可能首次落 vault(父目录还没建)→ save best-effort 建父目录。
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("browser-secrets").join("companion-new");
let path = secret_vault_path(&nested);
assert!(!nested.exists(), "precondition: parent not yet created");
let mut store = SecretStore::new(KEY);
store.register("pw", "v", vec!["x.com".into()]).unwrap();
save_secret_store(&store, &path).expect("save into not-yet-existing dir");
assert!(path.exists(), "save must create parent dirs");
assert_eq!(load_secret_store(&path, KEY).resolve("pw", "https://x.com").unwrap().expose(), "v");
}
#[test]
fn save_empty_store_round_trips() {
// 空 store(删光后)也能存取(登出/全删后落盘)。
let dir = tempfile::tempdir().expect("tempdir");
let path = secret_vault_path(dir.path());
let store = SecretStore::new(KEY);
save_secret_store(&store, &path).expect("save empty");
assert!(load_secret_store(&path, KEY).is_empty());
}
}