build: 补强签名Core发布预检
This commit is contained in:
@@ -1024,3 +1024,10 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 父测试在 `journal_recorded`、`pointer_recorded`、`switch_committed` 三个边界分别强制终止子进程 20 次。每个子进程先完成真实签名和包树校验,60 次恢复均得到完整旧 Host/Core 组合,无 pending 操作。
|
- 父测试在 `journal_recorded`、`pointer_recorded`、`switch_committed` 三个边界分别强制终止子进程 20 次。每个子进程先完成真实签名和包树校验,60 次恢复均得到完整旧 Host/Core 组合,无 pending 操作。
|
||||||
- A-04 已接入统一生产 Runner。正式结果为 `PASSED`,绑定提交 `36c497f879bcf1fe727e22f14751da25294fa55b`,报告位于 `H:\OpenNexus-acceptance\a04-36c497f\report`;报告确认 6 次 Core 崩溃、5 次自动重启、6 次本地摘要一致、60 次更新强杀和 0 个残留受管理后代。
|
- A-04 已接入统一生产 Runner。正式结果为 `PASSED`,绑定提交 `36c497f879bcf1fe727e22f14751da25294fa55b`,报告位于 `H:\OpenNexus-acceptance\a04-36c497f\report`;报告确认 6 次 Core 崩溃、5 次自动重启、6 次本地摘要一致、60 次更新强杀和 0 个残留受管理后代。
|
||||||
- 正式隔离 Cargo target 在验收后通过 `cargo clean` 删除,共清理 7274 个文件、约 5.8 GiB;报告、JUnit、脱敏日志和配置保留。A-04 可记为通过,正式验收累计 **23/30**;A-01、C-01 与 E-01~E-05 尚未通过。
|
- 正式隔离 Cargo target 在验收后通过 `cargo clean` 删除,共清理 7274 个文件、约 5.8 GiB;报告、JUnit、脱敏日志和配置保留。A-04 可记为通过,正式验收累计 **23/30**;A-01、C-01 与 E-01~E-05 尚未通过。
|
||||||
|
|
||||||
|
### 2026-09-12:A-01 发布 Core 本机预检补强
|
||||||
|
|
||||||
|
- `build-core.py` 生成的清单新增精确 `host_version` 和 `core_version`。显式 `--release` 模式要求从 `OPENNEXUS_CORE_SIGNING_KEY_FILE` 引用 Ed25519 PEM 私钥,输出 64 字节 `manifest.sig` 与 32 字节原始公钥;缺少密钥或密钥类型错误时拒绝生产构建,私钥不写入参数、仓库或产物。
|
||||||
|
- 构建成功后默认删除可重建的 PyInstaller `work` 目录;只有显式 `--keep-work` 才保留。当前构建得到 1523 个文件、223.56 MiB,低于 300 MiB 基础包门槛。
|
||||||
|
- 打包 Core 连续 20 次真实启动通过,每轮使用独立数据目录、不同会话代际、认证健康请求,并通过 Rust Workspace 写入及重读本地笔记;ready P95 为 2.676 秒,低于 10 秒。复制出的 Core 可执行文件修改一个字节后连续 20 次均在创建进程前返回 `CORE_INTEGRITY_FAILED`。
|
||||||
|
- 临时 Ed25519 PEM 夹具验证了发布签名输出格式;本机没有代码签名证书,Rust 工具链为 GNU 而非 MSVC,也不是干净标准用户离线 VM。因此这些结果是 A-01 本机预检,A-01 仍不计为通过。
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ fn bundle_rejects_modified_missing_and_extra_files() {
|
|||||||
#[ignore = "requires scripts/build-core.py; run explicitly after building the isolated Core"]
|
#[ignore = "requires scripts/build-core.py; run explicitly after building the isolated Core"]
|
||||||
fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
|
fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
|
||||||
use notesagent_host::core::CoreSupervisor;
|
use notesagent_host::core::CoreSupervisor;
|
||||||
|
use notesagent_host::workspace::Workspace;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
path::Path,
|
path::Path,
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar");
|
let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar");
|
||||||
let bundle = output.join("dist/opennexus-core").canonicalize().unwrap();
|
let bundle = output.join("dist/opennexus-core").canonicalize().unwrap();
|
||||||
@@ -39,7 +40,11 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
|
|||||||
"opennexus-core"
|
"opennexus-core"
|
||||||
});
|
});
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let vault = temp.path().join("vault");
|
||||||
|
std::fs::create_dir(&vault).unwrap();
|
||||||
|
let mut workspace = Workspace::open(&vault).unwrap();
|
||||||
let mut generations = HashSet::new();
|
let mut generations = HashSet::new();
|
||||||
|
let mut ready_times = Vec::new();
|
||||||
for attempt in 0..20 {
|
for attempt in 0..20 {
|
||||||
let mut core = CoreSupervisor::new(
|
let mut core = CoreSupervisor::new(
|
||||||
executable.clone(),
|
executable.clone(),
|
||||||
@@ -48,7 +53,9 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
|
|||||||
temp.path().join(format!("run-{attempt}")),
|
temp.path().join(format!("run-{attempt}")),
|
||||||
)
|
)
|
||||||
.with_bundle_manifest(manifest.clone());
|
.with_bundle_manifest(manifest.clone());
|
||||||
|
let started = Instant::now();
|
||||||
let request = core.request_session("/health").unwrap();
|
let request = core.request_session("/health").unwrap();
|
||||||
|
ready_times.push(started.elapsed());
|
||||||
assert!(generations.insert(request.generation.clone()));
|
assert!(generations.insert(request.generation.clone()));
|
||||||
let endpoint = request
|
let endpoint = request
|
||||||
.url
|
.url
|
||||||
@@ -66,8 +73,52 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
|
|||||||
"packaged health failed on attempt {attempt}"
|
"packaged health failed on attempt {attempt}"
|
||||||
);
|
);
|
||||||
assert!(!response.contains(request.authorization.as_str()));
|
assert!(!response.contains(request.authorization.as_str()));
|
||||||
|
let path = format!("cold-start-{attempt}.md");
|
||||||
|
let saved = workspace
|
||||||
|
.write(&path, "", format!("本地编辑 {attempt}").as_bytes(), "local")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(workspace.read(&path).unwrap().entry.hash, saved.hash);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
drop(core);
|
drop(core);
|
||||||
assert!(std::net::TcpStream::connect(endpoint).is_err());
|
assert!(std::net::TcpStream::connect(endpoint).is_err());
|
||||||
}
|
}
|
||||||
|
ready_times.sort();
|
||||||
|
let p95 = ready_times[18];
|
||||||
|
eprintln!("20 次打包 Core 冷启动 ready P95: {p95:?}");
|
||||||
|
assert!(p95 <= Duration::from_secs(10), "ready P95 超过 10 秒");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "需要先通过 scripts/build-core.py 构建隔离 Core"]
|
||||||
|
fn one_byte_tampered_packaged_core_is_refused_twenty_times() {
|
||||||
|
use notesagent_host::core::CoreSupervisor;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar");
|
||||||
|
let source = output.join("dist/opennexus-core/opennexus-core.exe");
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("core");
|
||||||
|
std::fs::create_dir(&root).unwrap();
|
||||||
|
let executable = root.join("opennexus-core.exe");
|
||||||
|
std::fs::copy(&source, &executable).unwrap();
|
||||||
|
let original = std::fs::read(&executable).unwrap();
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"protocol": 1,
|
||||||
|
"product": "OpenNexus",
|
||||||
|
"files": {"opennexus-core.exe": format!("{:x}", Sha256::digest(&original))}
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let mut tampered = original;
|
||||||
|
tampered[0] ^= 1;
|
||||||
|
std::fs::write(&executable, tampered).unwrap();
|
||||||
|
for attempt in 0..20 {
|
||||||
|
let mut core = CoreSupervisor::new(
|
||||||
|
executable.clone(),
|
||||||
|
vec![],
|
||||||
|
root.clone(),
|
||||||
|
temp.path().join(format!("tampered-{attempt}")),
|
||||||
|
)
|
||||||
|
.with_bundle_manifest(manifest.clone());
|
||||||
|
assert_eq!(core.start().unwrap_err(), "CORE_INTEGRITY_FAILED");
|
||||||
|
assert!(core.process_id().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-3
@@ -4,16 +4,56 @@
|
|||||||
输出保留在当前工作树中已忽略的 .build 目录内。
|
输出保留在当前工作树中已忽略的 .build 目录内。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--release",
|
||||||
|
action="store_true",
|
||||||
|
help="生成必须带 Ed25519 签名的生产 Core 发布包",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-work",
|
||||||
|
action="store_true",
|
||||||
|
help="保留可重建的 PyInstaller 中间目录用于诊断",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_release(manifest: bytes, output: Path) -> None:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
key_file = os.environ.get("OPENNEXUS_CORE_SIGNING_KEY_FILE", "")
|
||||||
|
if not key_file:
|
||||||
|
raise RuntimeError("release build requires OPENNEXUS_CORE_SIGNING_KEY_FILE")
|
||||||
|
key_path = Path(key_file).resolve(strict=True)
|
||||||
|
private = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
||||||
|
if not isinstance(private, Ed25519PrivateKey):
|
||||||
|
raise RuntimeError("Core signing key must be an Ed25519 PEM private key")
|
||||||
|
signature = private.sign(manifest)
|
||||||
|
public = private.public_key().public_bytes(
|
||||||
|
serialization.Encoding.Raw,
|
||||||
|
serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
(output / "manifest.sig").write_bytes(signature)
|
||||||
|
(output / "public-key.hex").write_text(public.hex() + "\n", encoding="ascii")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
options = arguments()
|
||||||
output = ROOT / ".build" / "sidecar"
|
output = ROOT / ".build" / "sidecar"
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
@@ -36,9 +76,25 @@ def main():
|
|||||||
if path.is_file():
|
if path.is_file():
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest()
|
files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
manifest = {"protocol": 1, "product": "OpenNexus", "files": files,
|
cargo = tomllib.loads((ROOT / "frontend" / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8"))
|
||||||
"lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest()}
|
version = cargo["package"]["version"]
|
||||||
(output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
manifest = {
|
||||||
|
"protocol": 1,
|
||||||
|
"product": "OpenNexus",
|
||||||
|
"host_version": version,
|
||||||
|
"core_version": version,
|
||||||
|
"files": files,
|
||||||
|
"lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest(),
|
||||||
|
}
|
||||||
|
encoded = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
(output / "manifest.json").write_bytes(encoded)
|
||||||
|
if options.release:
|
||||||
|
sign_release(encoded, output)
|
||||||
|
else:
|
||||||
|
for stale in (output / "manifest.sig", output / "public-key.hex"):
|
||||||
|
stale.unlink(missing_ok=True)
|
||||||
|
if not options.keep_work:
|
||||||
|
shutil.rmtree(output / "work", ignore_errors=True)
|
||||||
print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}")
|
print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user