From 43d7106cffe6d73c06d5b88a8b44c1029c3b9a23 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 09:58:17 +0800 Subject: [PATCH] =?UTF-8?q?test(credentials):=20=E7=99=BB=E8=AE=B0=20B-01?= =?UTF-8?q?=20=E7=94=9F=E4=BA=A7=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/OpenNexus生产验收Runner.md | 2 +- frontend/src-tauri/src/credentials.rs | 87 ++++++++++++ frontend/src-tauri/tests/core_process.rs | 14 ++ scripts/acceptance_cases/b01_credentials.py | 139 ++++++++++++++++++++ scripts/phase3_acceptance.py | 5 + 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 scripts/acceptance_cases/b01_credentials.py diff --git a/docs/development/OpenNexus生产验收Runner.md b/docs/development/OpenNexus生产验收Runner.md index d987096..498c914 100644 --- a/docs/development/OpenNexus生产验收Runner.md +++ b/docs/development/OpenNexus生产验收Runner.md @@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py ` 报告目录包含 `summary.json`、`case-manifest.json`、`junit.xml`、`cases/.json` 和脱敏的 `logs/.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。 -当前 runner 与失败闭合行为已实现,B-02 Fernet 迁移以及 S-01、S-02 Sync 客户端 driver 已登记;其余 27 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 +当前 runner 与失败闭合行为已实现,B-01、B-02 凭据以及 S-01、S-02 Sync 客户端 driver 已登记;其余 26 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index 71d7b71..3c262ea 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -1023,6 +1023,93 @@ mod tests { assert!(broker.resolve(&Scope::Provider, &id).unwrap().is_none()); } #[test] + fn b01_domain_matrix_never_exposes_or_cross_resolves_secrets() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("credentials.v1"); + let mut broker = CredentialBroker::new(file.clone()); + broker.unlock(password()).unwrap(); + let domains = [ + ("provider", Scope::Provider), + ("plugin", Scope::Plugin("reviewer".into())), + ("mcp", Scope::Mcp("calendar".into())), + ("sync", Scope::Sync("server-account".into())), + ]; + let mut records = Vec::new(); + for (label, scope) in &domains { + for index in 0..3 { + let id = CredentialId { + scope: scope.clone(), + id: format!("b01-{label}-{index}"), + }; + let secret = format!("b01-planted-{label}-{index}-value").into_bytes(); + broker.put(&id, Zeroizing::new(secret.clone())).unwrap(); + records.push((id, secret)); + } + } + let callers = domains + .iter() + .map(|(_, scope)| scope.clone()) + .collect::>(); + let mut public_errors = Vec::new(); + for (id, secret) in &records { + for caller in &callers { + if caller == &id.scope { + let resolved = broker.resolve(caller, id).unwrap().unwrap(); + assert!(resolved.as_slice() == secret, "same-domain secret mismatch"); + } else { + let error = broker.resolve(caller, id).unwrap_err(); + assert_eq!(error, "CREDENTIAL_SCOPE_DENIED"); + public_errors.push(error); + } + } + let wrong_owner = match &id.scope { + Scope::Plugin(_) => Some(Scope::Plugin("another-plugin".into())), + Scope::Mcp(_) => Some(Scope::Mcp("another-mcp".into())), + Scope::Sync(_) => Some(Scope::Sync("another-sync-account".into())), + Scope::Provider => None, + }; + if let Some(wrong_owner) = wrong_owner { + let error = broker.resolve(&wrong_owner, id).unwrap_err(); + assert_eq!(error, "CREDENTIAL_SCOPE_DENIED"); + public_errors.push(error); + } + } + assert_eq!(records.len(), 12); + assert_eq!(public_errors.len(), 45); + assert_eq!(broker.list().unwrap().len(), 12); + + let public_status = serde_json::to_vec(&serde_json::json!({ + "locked": broker.is_locked(), + "credentials": broker.list().unwrap(), + "errors": public_errors, + })) + .unwrap(); + let vault = tempfile::tempdir().unwrap(); + let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap(); + workspace + .write("note.md", "", b"safe note", "local") + .unwrap(); + let binding = workspace + .sync_bind_empty("https://sync.example", "remote", "account") + .unwrap(); + let job = workspace.sync_next(&binding.id).unwrap().unwrap(); + let sync_payload = + serde_json::to_vec(&workspace.sync_commit_payload(&job).unwrap()).unwrap(); + let encrypted = fs::read(&file).unwrap(); + for (_, secret) in &records { + for exposed in [&encrypted, &public_status, &sync_payload] { + assert!( + !exposed.windows(secret.len()).any(|window| window == secret), + "secret appeared outside the scoped resolver" + ); + } + } + broker.lock(); + assert!(records.iter().all(|(id, _)| broker + .resolve(&id.scope, id) + .is_err_and(|error| error == "CREDENTIALS_LOCKED"))); + } + #[test] fn corrupt_store_is_not_recreated() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("credentials.v1"); diff --git a/frontend/src-tauri/tests/core_process.rs b/frontend/src-tauri/tests/core_process.rs index 8921b85..3b3bb94 100644 --- a/frontend/src-tauri/tests/core_process.rs +++ b/frontend/src-tauri/tests/core_process.rs @@ -101,6 +101,20 @@ fn real_core_credential_api_uses_host_stronghold_without_plaintext_response() { "credential API did not succeed" ); assert!(!response.contains("fixture-credential-via-host-pipe")); + let status_session = core + .request_session("/api/credentials/fixture-provider") + .unwrap(); + let mut socket = std::net::TcpStream::connect(endpoint).unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .unwrap(); + write!(socket, "GET /api/credentials/fixture-provider HTTP/1.1\r\nHost: {endpoint}\r\nAuthorization: {}\r\nX-Core-Generation: {}\r\nConnection: close\r\n\r\n", status_session.authorization.as_str(), status_session.generation).unwrap(); + let mut status_response = String::new(); + socket.read_to_string(&mut status_response).unwrap(); + assert!(status_response.starts_with("HTTP/1.1 200")); + assert!(status_response.contains("\"credential_id\":\"fixture-provider\"")); + assert!(status_response.contains("\"configured\":true")); + assert!(!status_response.contains("fixture-credential-via-host-pipe")); let id = CredentialId { scope: Scope::Provider, id: "fixture-provider".into(), diff --git a/scripts/acceptance_cases/b01_credentials.py b/scripts/acceptance_cases/b01_credentials.py new file mode 100644 index 0000000..db727d5 --- /dev/null +++ b/scripts/acceptance_cases/b01_credentials.py @@ -0,0 +1,139 @@ +"""B-01 scoped Stronghold and plaintext-exposure acceptance driver.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +MATRIX_TEST = "credentials::tests::b01_domain_matrix_never_exposes_or_cross_resolves_secrets" +CORE_TEST = "real_core_credential_api_uses_host_stronghold_without_plaintext_response" +SYNC_TEST = "sync_auth::tests::lock_cancels_inflight_and_scopes_do_not_expose_tokens" +PLANTED_VALUES = ( + "b01-planted-", + "fixture-credential-via-host-pipe", + "private-access", + "private-refresh", +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_exact(command: list[str]) -> tuple[bool, str]: + completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) + transcript = completed.stdout + completed.stderr + print(transcript, end="") + passed = completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout + return passed, transcript + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "") + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + assertions = [ + {"name": "Provider, Plugin, MCP, and Sync each retain three same-domain secrets"}, + {"name": "all thirty-six cross-domain and nine cross-owner resolutions are denied"}, + {"name": "real Core credential write and status responses expose no plaintext"}, + {"name": "Stronghold bytes, public metadata, and sync payloads contain no planted secret"}, + {"name": "captured acceptance logs contain no planted secret or Sync token"}, + ] + cargo = shutil.which("cargo") + transcripts: list[str] = [] + passed = case_id == "B-01" and cargo is not None + if passed: + commands = [ + [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--lib", + MATRIX_TEST, + "--", + "--exact", + "--nocapture", + ], + [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + "--lib", + SYNC_TEST, + "--", + "--exact", + "--nocapture", + ], + [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + "--test", + "core_process", + CORE_TEST, + "--", + "--exact", + "--nocapture", + ], + ] + for command in commands: + command_passed, transcript = run_exact(command) + transcripts.append(transcript) + passed = command_passed and passed + leaked = any(value in "".join(transcripts) for value in PLANTED_VALUES) + passed = passed and not leaked + status = "PASSED" if passed else "FAILED" + evidence = f"cargo exact tests {MATRIX_TEST}, {SYNC_TEST}, and {CORE_TEST}" + for assertion in assertions: + assertion.update({"status": status, "evidence": evidence}) + files = [] + for relative in ( + "frontend/src-tauri/src/credentials.rs", + "frontend/src-tauri/src/sync_auth.rs", + "frontend/src-tauri/tests/core_process.rs", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "A B-01 exact oracle failed or its transcript exposed a planted value.", + "assertions": assertions, + "metrics": {"peak_rss_bytes": None, "max_process_count": None, "denied_access_count": None}, + "files": files, + "revisions": [ + {"scope": "Stronghold domains", "domain_count": 4, "secrets_per_domain": 3}, + {"scope": "resolver denial matrix", "cross_domain_checks": 36, "cross_owner_checks": 9}, + ], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index bd7ef9e..436ff1f 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -32,6 +32,11 @@ ALL_CASES = tuple(case for cases in CASE_SUITES.values() for case in cases) # A case becomes executable only when a repository-owned driver is registered here. # Component/unit test commands are deliberately not treated as production acceptance. CASE_DRIVERS: dict[str, dict[str, Any]] = { + "B-01": { + "driver": "scripts/acceptance_cases/b01_credentials.py", + "timeout_seconds": 900, + "required_metrics": (), + }, "B-02": { "driver": "scripts/acceptance_cases/b02_credentials.py", "timeout_seconds": 900,