test(credentials): 完成 B-04 迁移恢复验收

This commit is contained in:
2026-09-09 19:59:10 +08:00
parent e39bf7b02b
commit db101085d9
6 changed files with 844 additions and 30 deletions
+569 -30
View File
@@ -26,6 +26,121 @@ const CLIENT: &[u8] = b"opennexus.credentials.v1";
const MAGIC: &[u8] = b"ONXCRED1";
const MAX_FILE: u64 = 16 * 1024 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct MigrationState {
schema: u32,
owner: String,
state: String,
source_sha256: String,
count: usize,
environment_key: bool,
migration_id: String,
}
impl MigrationState {
fn validate(&self) -> Result<()> {
if self.schema != 1
|| self.owner != "OpenNexus"
|| !matches!(
self.state.as_str(),
"backed_up"
| "copied"
| "verified"
| "switched"
| "cleanup_authorized"
| "cleanup_confirmed"
)
|| self.source_sha256.len() != 64
|| !self
.source_sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| self.migration_id.len() != 64
|| !self
.migration_id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err("MIGRATION_STATE_INVALID".into());
}
Ok(())
}
fn same_migration(&self, other: &Self) -> bool {
self.schema == other.schema
&& self.owner == other.owner
&& self.source_sha256 == other.source_sha256
&& self.count == other.count
&& self.environment_key == other.environment_key
&& self.migration_id == other.migration_id
}
}
#[derive(Clone, Serialize)]
pub struct MigrationCleanupPreview {
pub source_sha256: String,
pub count: usize,
pub environment_key: bool,
pub cleanup_complete: bool,
}
fn is_reparse_point(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
let _ = metadata;
false
}
}
fn ordinary_file(path: &Path, maximum: u64, error: &str) -> Result<fs::Metadata> {
let metadata = fs::symlink_metadata(path).map_err(|_| error.to_string())?;
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| is_reparse_point(&metadata)
|| metadata.len() > maximum
{
return Err(error.into());
}
Ok(metadata)
}
fn ordinary_directory(path: &Path, error: &str) -> Result<fs::Metadata> {
let metadata = fs::symlink_metadata(path).map_err(|_| error.to_string())?;
if !metadata.is_dir() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(error.into());
}
Ok(metadata)
}
fn persist_state(path: &Path, state: &MigrationState) -> Result<()> {
state.validate()?;
let parent = path.parent().ok_or("MIGRATION_STATE_INVALID")?;
fs::create_dir_all(parent).map_err(|_| "MIGRATION_SWITCH_FAILED")?;
let bytes = serde_json::to_vec(state).map_err(|_| "MIGRATION_STATE_INVALID")?;
let mut file =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "MIGRATION_SWITCH_FAILED")?;
file.write_all(&bytes)
.and_then(|_| file.as_file().sync_all())
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
file.persist(path).map_err(|_| "MIGRATION_SWITCH_FAILED")?;
Ok(())
}
fn read_state(path: &Path) -> Result<MigrationState> {
ordinary_file(path, 64 * 1024, "MIGRATION_STATE_INVALID")?;
let state: MigrationState =
serde_json::from_slice(&fs::read(path).map_err(|_| "MIGRATION_STATE_INVALID")?)
.map_err(|_| "MIGRATION_STATE_INVALID")?;
state.validate()?;
Ok(state)
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "owner", rename_all = "snake_case")]
pub enum Scope {
@@ -202,6 +317,34 @@ impl Unlocked {
}
}
fn verify_migration_backup(
session: &Unlocked,
backup: &Path,
source_sha256: &str,
) -> Result<Zeroizing<Vec<u8>>> {
ordinary_directory(backup, "MIGRATION_BACKUP_FAILED")?;
let source_backup = backup.join("credentials.json");
let key_backup = backup.join("master-key.sealed");
ordinary_file(&source_backup, MAX_FILE, "MIGRATION_BACKUP_FAILED")?;
ordinary_file(&key_backup, MAX_FILE, "MIGRATION_BACKUP_FAILED")?;
if format!(
"{:x}",
Sha256::digest(fs::read(source_backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?)
) != source_sha256
{
return Err("MIGRATION_BACKUP_FAILED".into());
}
let sealed = fs::read(key_backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
if sealed.len() <= 51 || &sealed[..7] != b"ONXFBK1" || sealed[7..39] != session.salt {
return Err("MIGRATION_BACKUP_FAILED".into());
}
session
.cipher()?
.decrypt(Nonce::from_slice(&sealed[39..51]), &sealed[51..])
.map(Zeroizing::new)
.map_err(|_| "MIGRATION_BACKUP_FAILED".into())
}
pub struct CredentialBroker {
path: PathBuf,
unlocked: Option<Unlocked>,
@@ -219,6 +362,14 @@ impl CredentialBroker {
&mut self,
directory: &Path,
environment_key: Option<Zeroizing<String>>,
) -> Result<usize> {
self.import_fernet_inner(directory, environment_key, |_| Ok(()))
}
fn import_fernet_inner(
&mut self,
directory: &Path,
environment_key: Option<Zeroizing<String>>,
mut checkpoint: impl FnMut(&str) -> Result<()>,
) -> Result<usize> {
use fs2::FileExt;
let directory = directory
@@ -226,12 +377,7 @@ impl CredentialBroker {
.map_err(|_| "MIGRATION_SOURCE_INVALID")?;
let source = directory.join("credentials.json");
let key_path = directory.join("master.key");
if !fs::symlink_metadata(&source)
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
.is_file()
{
return Err("MIGRATION_SOURCE_INVALID".into());
}
ordinary_file(&source, MAX_FILE, "MIGRATION_SOURCE_INVALID")?;
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
@@ -241,13 +387,6 @@ impl CredentialBroker {
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
lock.try_lock_exclusive()
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
if fs::metadata(&source)
.map_err(|_| "MIGRATION_SOURCE_INVALID")?
.len()
> MAX_FILE
{
return Err("MIGRATION_SOURCE_INVALID".into());
}
let source_bytes = fs::read(&source).map_err(|_| "MIGRATION_SOURCE_INVALID")?;
let source_hash = format!("{:x}", Sha256::digest(&source_bytes));
let tokens: BTreeMap<String, String> =
@@ -256,12 +395,7 @@ impl CredentialBroker {
return Err("MIGRATION_SOURCE_INVALID".into());
}
let local_key = if environment_key.is_none() {
if !fs::symlink_metadata(&key_path)
.map_err(|_| "MIGRATION_KEY_MISSING")?
.is_file()
{
return Err("MIGRATION_KEY_MISSING".into());
}
ordinary_file(&key_path, 4096, "MIGRATION_KEY_MISSING")?;
Some(Zeroizing::new(
fs::read_to_string(&key_path).map_err(|_| "MIGRATION_KEY_MISSING")?,
))
@@ -303,6 +437,27 @@ impl CredentialBroker {
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("migration-backups")
.join(&migration_id);
let journal = self
.path
.parent()
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("migration-state")
.join(format!("{migration_id}.json"));
let owner_path = directory.join(".opennexus-owner.json");
if owner_path.exists() {
let owner = read_state(&owner_path)?;
if owner.state == "cleanup_authorized" || owner.state == "cleanup_confirmed" {
return Err("MIGRATION_CLEANUP_ALREADY_AUTHORIZED".into());
}
if owner.state != "switched"
|| owner.source_sha256 != source_hash
|| owner.count != decoded.len()
|| owner.environment_key != environment_key.is_some()
|| owner.migration_id != migration_id
{
return Err("MIGRATION_SOURCE_CHANGED".into());
}
}
fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
// Backups contain ciphertext; the legacy key is sealed under the already
// unlocked device key, rather than adding another plaintext master.key.
@@ -332,11 +487,25 @@ impl CredentialBroker {
.persist(backup.join(name))
.map_err(|_| "MIGRATION_BACKUP_FAILED")?;
}
let mut state = MigrationState {
schema: 1,
owner: "OpenNexus".into(),
state: "backed_up".into(),
source_sha256: source_hash.clone(),
count: decoded.len(),
environment_key: environment_key.is_some(),
migration_id: migration_id.clone(),
};
persist_state(&journal, &state)?;
checkpoint("backed_up")?;
let result = (|| {
for (key, value) in &decoded {
session.write(key.clone(), value)?;
}
session.persist(&self.path)?;
state.state = "copied".into();
persist_state(&journal, &state)?;
checkpoint("copied")?;
// Re-open the committed Stronghold snapshot, not the in-memory cache.
let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let mut temporary =
@@ -365,17 +534,16 @@ impl CredentialBroker {
if fs::read(&source).map_err(|_| "MIGRATION_SOURCE_CHANGED")? != source_bytes {
return Err("MIGRATION_SOURCE_CHANGED".into());
}
let marker = serde_json::json!({"schema":1,"owner":"OpenNexus","state":"switched","source_sha256":source_hash,"count":decoded.len(),"environment_key":environment_key.is_some()});
let bytes = serde_json::to_vec(&marker).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let mut marker_file = tempfile::NamedTempFile::new_in(&directory)
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
marker_file
.write_all(&bytes)
.and_then(|_| marker_file.as_file().sync_all())
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
marker_file
.persist(directory.join(".opennexus-owner.json"))
.map_err(|_| "MIGRATION_SWITCH_FAILED")?;
state.state = "verified".into();
persist_state(&journal, &state)?;
checkpoint("verified")?;
let mut owner = state.clone();
owner.state = "switched".into();
persist_state(&owner_path, &owner)?;
checkpoint("ownership_committed")?;
state.state = "switched".into();
persist_state(&journal, &state)?;
checkpoint("switched")?;
Ok(decoded.len())
})();
if result.is_err() {
@@ -383,6 +551,153 @@ impl CredentialBroker {
}
result
}
/// Deletes only the verified legacy files and this migration's encrypted backup.
/// The native Host must obtain explicit user confirmation for source_sha256 first.
pub fn cleanup_fernet(
&mut self,
directory: &Path,
confirmed_source_sha256: &str,
) -> Result<()> {
self.cleanup_fernet_inner(directory, confirmed_source_sha256, |_| Ok(()))
}
pub fn cleanup_fernet_preview(&self, directory: &Path) -> Result<MigrationCleanupPreview> {
self.session()?;
let directory = directory
.canonicalize()
.map_err(|_| "MIGRATION_SOURCE_INVALID")?;
ordinary_directory(&directory, "MIGRATION_SOURCE_INVALID")?;
let state = read_state(&directory.join(".opennexus-owner.json"))?;
if !matches!(
state.state.as_str(),
"switched" | "cleanup_authorized" | "cleanup_confirmed"
) {
return Err("MIGRATION_STATE_INVALID".into());
}
Ok(MigrationCleanupPreview {
source_sha256: state.source_sha256,
count: state.count,
environment_key: state.environment_key,
cleanup_complete: state.state == "cleanup_confirmed",
})
}
fn cleanup_fernet_inner(
&mut self,
directory: &Path,
confirmed_source_sha256: &str,
mut checkpoint: impl FnMut(&str) -> Result<()>,
) -> Result<()> {
use fs2::FileExt;
let session = self.session()?;
let directory = directory
.canonicalize()
.map_err(|_| "MIGRATION_SOURCE_INVALID")?;
ordinary_directory(&directory, "MIGRATION_SOURCE_INVALID")?;
let lock_path = directory.join(".migration.lock");
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
lock.try_lock_exclusive()
.map_err(|_| "MIGRATION_SOURCE_BUSY")?;
let owner_path = directory.join(".opennexus-owner.json");
let mut state = read_state(&owner_path)?;
if state.state == "cleanup_confirmed" {
return Ok(());
}
if !matches!(state.state.as_str(), "switched" | "cleanup_authorized")
|| state.source_sha256 != confirmed_source_sha256
{
return Err("MIGRATION_CLEANUP_CONFIRMATION".into());
}
let source = directory.join("credentials.json");
let parent = self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
let journal = parent
.join("migration-state")
.join(format!("{}.json", state.migration_id));
let journal_state = read_state(&journal)?;
if !journal_state.same_migration(&state)
|| !matches!(
journal_state.state.as_str(),
"switched" | "cleanup_authorized" | "cleanup_confirmed"
)
{
return Err("MIGRATION_STATE_INVALID".into());
}
let backup = parent.join("migration-backups").join(&state.migration_id);
let sealed_key = if backup.exists() {
Some(verify_migration_backup(
session,
&backup,
&state.source_sha256,
)?)
} else {
None
};
if state.state == "switched" && sealed_key.is_none() {
return Err("MIGRATION_BACKUP_FAILED".into());
}
if source.exists() {
ordinary_file(&source, MAX_FILE, "MIGRATION_SOURCE_CHANGED")?;
if format!(
"{:x}",
Sha256::digest(fs::read(&source).map_err(|_| "MIGRATION_SOURCE_CHANGED")?)
) != state.source_sha256
{
return Err("MIGRATION_SOURCE_CHANGED".into());
}
} else if state.state == "switched" {
return Err("MIGRATION_SOURCE_CHANGED".into());
}
let key_path = directory.join("master.key");
if !state.environment_key && key_path.exists() {
ordinary_file(&key_path, 4096, "MIGRATION_CLEANUP_FAILED")?;
let expected_key = sealed_key.as_ref().ok_or("MIGRATION_CLEANUP_FAILED")?;
if fs::read(&key_path).map_err(|_| "MIGRATION_CLEANUP_FAILED")?
!= expected_key.as_slice()
{
return Err("MIGRATION_CLEANUP_FAILED".into());
}
}
if state.state == "cleanup_authorized"
&& sealed_key.is_none()
&& (source.exists() || (!state.environment_key && key_path.exists()))
{
return Err("MIGRATION_CLEANUP_FAILED".into());
}
if state.state == "switched" {
state.state = "cleanup_authorized".into();
persist_state(&owner_path, &state)?;
checkpoint("cleanup_authorized")?;
persist_state(&journal, &state)?;
}
if source.exists() {
ordinary_file(&source, MAX_FILE, "MIGRATION_CLEANUP_FAILED")?;
fs::remove_file(&source).map_err(|_| "MIGRATION_CLEANUP_FAILED")?;
}
checkpoint("source_removed")?;
if !state.environment_key && key_path.exists() {
ordinary_file(&key_path, 4096, "MIGRATION_CLEANUP_FAILED")?;
fs::remove_file(key_path).map_err(|_| "MIGRATION_CLEANUP_FAILED")?;
}
checkpoint("key_removed")?;
if backup.exists() {
ordinary_directory(&backup, "MIGRATION_CLEANUP_FAILED")?;
fs::remove_dir_all(&backup).map_err(|_| "MIGRATION_CLEANUP_FAILED")?;
}
checkpoint("backup_removed")?;
state.state = "cleanup_confirmed".into();
persist_state(&journal, &state)?;
checkpoint("cleanup_recorded")?;
persist_state(&owner_path, &state)?;
checkpoint("cleanup_confirmed")?;
Ok(())
}
pub fn dispatch(&mut self, request: &serde_json::Value) -> Result<serde_json::Value> {
let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?;
let params = &request["params"];
@@ -734,6 +1049,32 @@ mod tests {
fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"test-only-password-123".to_vec())
}
fn b04_fixture() -> (Vec<u8>, String, BTreeMap<String, String>) {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
let mut tokens = BTreeMap::new();
let mut values = BTreeMap::new();
for id in ["provider-000", "provider-001", "provider-002"] {
tokens.insert(
id.to_string(),
fixture["tokens"][id].as_str().unwrap().to_string(),
);
values.insert(
id.to_string(),
fixture["values"][id].as_str().unwrap().to_string(),
);
}
(
serde_json::to_vec(&tokens).unwrap(),
fixture["key"].as_str().unwrap().to_string(),
values,
)
}
fn b04_write_source(directory: &Path, source: &[u8], key: &str) {
fs::create_dir_all(directory).unwrap();
fs::write(directory.join("credentials.json"), source).unwrap();
fs::write(directory.join("master.key"), key).unwrap();
}
#[test]
fn encrypted_backup_restores_corrupt_store_without_overwrite_on_failure() {
let temp = tempfile::tempdir().unwrap();
@@ -979,6 +1320,204 @@ mod tests {
);
}
#[test]
#[ignore = "parent acceptance oracle hard-terminates this helper at a durable boundary"]
fn b04_migration_boundary_worker() {
let Some(source) = std::env::var_os("OPENNEXUS_B04_SOURCE") else {
return;
};
let target = PathBuf::from(std::env::var_os("OPENNEXUS_B04_TARGET").unwrap());
let boundary = std::env::var("OPENNEXUS_B04_BOUNDARY").unwrap();
let marker = PathBuf::from(std::env::var_os("OPENNEXUS_B04_MARKER").unwrap());
let mut broker = CredentialBroker::new(target);
broker.unlock(password()).unwrap();
let _ = broker.import_fernet_inner(Path::new(&source), None, |at| {
if at == boundary {
let file = fs::File::create(&marker).unwrap();
file.sync_all().unwrap();
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
Ok(())
});
panic!("B-04 helper passed the requested boundary");
}
#[test]
fn b04_migration_survives_twenty_hard_terminations_per_boundary() {
let (source, legacy_key, expected) = b04_fixture();
for boundary in [
"backed_up",
"copied",
"verified",
"ownership_committed",
"switched",
] {
for round in 0..20 {
let temp = tempfile::tempdir().unwrap();
let legacy = temp.path().join("legacy");
let target = temp.path().join("new/stronghold.v1");
b04_write_source(&legacy, &source, &legacy_key);
let marker = temp.path().join(format!("{boundary}-{round}.ready"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"credentials::tests::b04_migration_boundary_worker",
"--nocapture",
])
.env("OPENNEXUS_B04_SOURCE", &legacy)
.env("OPENNEXUS_B04_TARGET", &target)
.env("OPENNEXUS_B04_BOUNDARY", boundary)
.env("OPENNEXUS_B04_MARKER", &marker)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let started = std::time::Instant::now();
while !marker.is_file() {
assert!(
child.try_wait().unwrap().is_none(),
"helper exited before {boundary}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(30),
"helper did not reach {boundary}"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
child.kill().unwrap();
assert!(!child.wait().unwrap().success());
assert_eq!(fs::read(legacy.join("credentials.json")).unwrap(), source);
assert_eq!(
fs::read_to_string(legacy.join("master.key")).unwrap(),
legacy_key
);
let owner_exists = legacy.join(".opennexus-owner.json").is_file();
let mut broker = CredentialBroker::new(target);
broker.unlock(password()).unwrap();
let before_count = broker.list().unwrap().len();
assert!(before_count == 0 || before_count == expected.len());
if owner_exists {
assert_eq!(before_count, expected.len());
}
assert_eq!(broker.import_fernet(&legacy, None).unwrap(), expected.len());
assert_eq!(broker.list().unwrap().len(), expected.len());
for (id, value) in &expected {
assert_eq!(
broker
.resolve(&Scope::Provider, &CredentialId::legacy(id))
.unwrap()
.unwrap()
.as_slice(),
value.as_bytes()
);
}
let owner = read_state(&legacy.join(".opennexus-owner.json")).unwrap();
let journal = read_state(
&broker
.path
.parent()
.unwrap()
.join("migration-state")
.join(format!("{}.json", owner.migration_id)),
)
.unwrap();
assert_eq!(owner.state, "switched");
assert_eq!(journal, owner);
}
}
}
#[test]
fn b04_cleanup_is_confirmed_scoped_and_resumable() {
let (source, legacy_key, expected) = b04_fixture();
for boundary in [
"cleanup_authorized",
"source_removed",
"key_removed",
"backup_removed",
"cleanup_recorded",
"cleanup_confirmed",
] {
let temp = tempfile::tempdir().unwrap();
let legacy = temp.path().join("legacy");
b04_write_source(&legacy, &source, &legacy_key);
let mut broker = CredentialBroker::new(temp.path().join("new/stronghold.v1"));
broker.unlock(password()).unwrap();
broker.import_fernet(&legacy, None).unwrap();
let state = read_state(&legacy.join(".opennexus-owner.json")).unwrap();
let backup = broker
.path
.parent()
.unwrap()
.join("migration-backups")
.join(&state.migration_id);
assert!(legacy.join("credentials.json").is_file());
assert!(legacy.join("master.key").is_file());
assert!(backup.is_dir());
assert_eq!(
broker.cleanup_fernet(&legacy, &"0".repeat(64)).unwrap_err(),
"MIGRATION_CLEANUP_CONFIRMATION"
);
assert!(legacy.join("credentials.json").is_file());
assert!(legacy.join("master.key").is_file());
assert!(backup.is_dir());
let mut stopped = false;
assert_eq!(
broker
.cleanup_fernet_inner(&legacy, &state.source_sha256, |at| {
if at == boundary && !stopped {
stopped = true;
return Err("POWER_CUT".into());
}
Ok(())
})
.unwrap_err(),
"POWER_CUT"
);
broker
.cleanup_fernet(&legacy, &state.source_sha256)
.unwrap();
assert!(!legacy.join("credentials.json").exists());
assert!(!legacy.join("master.key").exists());
assert!(!backup.exists());
let completed = read_state(&legacy.join(".opennexus-owner.json")).unwrap();
assert_eq!(completed.state, "cleanup_confirmed");
assert_eq!(completed.count, expected.len());
broker
.cleanup_fernet(&legacy, &state.source_sha256)
.unwrap();
}
let temp = tempfile::tempdir().unwrap();
let legacy = temp.path().join("environment-legacy");
let external_key_file = temp.path().join("external-environment-key.txt");
fs::create_dir_all(&legacy).unwrap();
fs::write(legacy.join("credentials.json"), &source).unwrap();
fs::write(legacy.join("master.key"), b"unmanaged-sentinel").unwrap();
fs::write(&external_key_file, &legacy_key).unwrap();
let external_before = fs::read(&external_key_file).unwrap();
let mut broker = CredentialBroker::new(temp.path().join("environment/stronghold.v1"));
broker.unlock(password()).unwrap();
broker
.import_fernet(&legacy, Some(Zeroizing::new(legacy_key)))
.unwrap();
let state = read_state(&legacy.join(".opennexus-owner.json")).unwrap();
assert!(state.environment_key);
broker
.cleanup_fernet(&legacy, &state.source_sha256)
.unwrap();
assert!(!legacy.join("credentials.json").exists());
assert_eq!(
fs::read(legacy.join("master.key")).unwrap(),
b"unmanaged-sentinel"
);
assert_eq!(fs::read(external_key_file).unwrap(), external_before);
assert_eq!(broker.list().unwrap().len(), expected.len());
}
#[test]
fn stronghold_roundtrip_scope_lock_and_password_rotation() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("credentials.v1");
+72
View File
@@ -16,6 +16,7 @@ use notesagent_host::credentials::CredentialBroker;
use notesagent_host::recent::{RecentVault, RecentVaultStore};
use notesagent_host::request_lifecycle::Requests;
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
@@ -606,6 +607,76 @@ async fn credentials_import(host: State<'_, Host>) -> Result<Option<usize>, Stri
.map_err(|_| "HOST_BUSY")?
}
#[derive(Serialize)]
struct CredentialCleanupResult {
count: usize,
already_clean: bool,
}
#[tauri::command]
async fn credentials_cleanup(
host: State<'_, Host>,
) -> Result<Option<CredentialCleanupResult>, String> {
let Some(directory) = rfd::FileDialog::new()
.set_title("选择已完成凭据迁移的旧版本目录")
.pick_folder()
else {
return Ok(None);
};
let broker = host.credentials.clone();
let preview_directory = directory.clone();
let preview = tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_ref()
.ok_or("HOST_NOT_READY")?
.cleanup_fernet_preview(&preview_directory)
})
.await
.map_err(|_| "HOST_BUSY")??;
if preview.cleanup_complete {
return Ok(Some(CredentialCleanupResult {
count: preview.count,
already_clean: true,
}));
}
let key_description = if preview.environment_key {
"外部环境密钥不会被修改。"
} else {
"旧目录内由本次迁移管理的 master.key 也会被删除。"
};
let description = format!(
"已验证 {} 条凭据。将永久删除旧 credentials.json 和本次迁移创建的加密备份。{}\n\n源文件 SHA-256{}\n\n是否继续?",
preview.count, key_description, preview.source_sha256
);
if rfd::MessageDialog::new()
.set_title("清理已迁移的旧凭据")
.set_description(description)
.set_buttons(rfd::MessageButtons::YesNo)
.show()
!= rfd::MessageDialogResult::Yes
{
return Ok(None);
}
let broker = host.credentials.clone();
let source_sha256 = preview.source_sha256;
tauri::async_runtime::spawn_blocking(move || {
broker
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("HOST_NOT_READY")?
.cleanup_fernet(&directory, &source_sha256)
})
.await
.map_err(|_| "HOST_BUSY")??;
Ok(Some(CredentialCleanupResult {
count: preview.count,
already_clean: false,
}))
}
#[tauri::command]
async fn credentials_change_password(
host: State<'_, Host>,
@@ -1013,6 +1084,7 @@ fn main() {
credentials_lock,
credentials_change_password,
credentials_import,
credentials_cleanup,
credentials_backup,
credentials_restore,
core_request,
@@ -35,3 +35,18 @@ it('clears the restore password and treats native cancellation as no change', as
expect(wrapper.text()).not.toContain('已恢复')
wrapper.unmount()
})
it('runs native-confirmed cleanup without exposing a path or confirmation to the WebView', async () => {
vi.mocked(hostInvoke).mockImplementation(async command => {
if (command === 'credentials_status') return { locked: false }
if (command === 'credentials_cleanup') return { count: 3, already_clean: false }
return null
})
const wrapper = mount(CredentialVaultSettings)
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('清理已迁移'))!.trigger('click')
await flushPromises()
expect(hostInvoke).toHaveBeenCalledWith('credentials_cleanup')
expect(wrapper.text()).toContain('已清理 3 条已迁移凭据的旧文件')
wrapper.unmount()
})
@@ -26,6 +26,15 @@ async function importLegacy() {
} catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') }
finally { busy.value = false }
}
async function cleanupLegacy() {
busy.value = true; message.value = ''
try {
const result = await hostInvoke<{ count: number; already_clean: boolean } | null>('credentials_cleanup')
if (result?.already_clean) message.value = t('这次迁移的旧凭据已清理。', 'Legacy credentials for this migration are already cleaned up.')
else if (result) message.value = t(`已清理 ${result.count} 条已迁移凭据的旧文件。`, `Cleaned up legacy files for ${result.count} migrated credentials.`)
} catch (error) { message.value = failureMessage(error, 'MIGRATION_CLEANUP_FAILED') }
finally { busy.value = false }
}
async function backup() {
busy.value = true; message.value = ''
try {
@@ -85,6 +94,7 @@ onUnmounted(() => clearInterval(statusTimer))
<button class="button-primary" type="submit" :disabled="busy">{{ busy ? t('处理中…', 'Working…') : locked ? t('解锁', 'Unlock') : t('更改口令', 'Change password') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="act('lock')">{{ t('立即锁定', 'Lock now') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="importLegacy">{{ t('迁移旧凭据', 'Import legacy credentials') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="cleanupLegacy">{{ t('清理已迁移旧凭据', 'Clean up migrated credentials') }}</button>
<button v-if="!locked" class="button-secondary" type="button" :disabled="busy" @click="backup">{{ t('导出加密备份', 'Export encrypted backup') }}</button>
<button v-if="locked" class="button-secondary" type="button" :disabled="busy" @click="restore">{{ t('使用此口令恢复备份', 'Restore backup with this password') }}</button>
</div>
+162
View File
@@ -0,0 +1,162 @@
"""B-04 transactional credential migration and confirmed cleanup oracle."""
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"
RUST_TESTS = (
"credentials::tests::b04_migration_survives_twenty_hard_terminations_per_boundary",
"credentials::tests::b04_cleanup_is_confirmed_scoped_and_resumable",
)
PYTHON_TEST = (
ROOT / "backend" / "tests" / "test_credentials.py",
"test_migrated_fernet_owner_blocks_old_reads_and_writes",
)
UI_TEST = ROOT / "frontend" / "src" / "features" / "settings" / "CredentialVaultSettings.spec.ts"
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(command: list[str], expected: str) -> bool:
completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False)
transcript = completed.stdout + completed.stderr
print(transcript, end="")
return completed.returncode == 0 and expected in transcript
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
cargo = shutil.which(os.environ.get("CARGO", "cargo"))
python = ROOT / "backend" / ".venv" / "Scripts" / "python.exe"
pnpm = shutil.which("pnpm.cmd") or shutil.which("pnpm")
passed = (
case_id == "B-04"
and os.name == "nt"
and cargo is not None
and python.is_file()
and pnpm is not None
)
if passed:
for test in RUST_TESTS:
passed = run(
[
cargo,
"test",
"--manifest-path",
str(MANIFEST),
"--locked",
"--features",
"desktop",
"--lib",
test,
"--",
"--exact",
"--nocapture",
"--test-threads=1",
],
"1 passed; 0 failed",
) and passed
passed = run(
[
str(python),
"-m",
"pytest",
"-q",
f"{PYTHON_TEST[0]}::{PYTHON_TEST[1]}",
],
"1 passed",
) and passed
passed = run(
[
pnpm,
"--dir",
"frontend",
"exec",
"vitest",
"run",
str(UI_TEST.relative_to(ROOT / "frontend")),
"-t",
"runs native-confirmed cleanup",
],
"1 passed",
) and passed
status = "PASSED" if passed else "FAILED"
evidence = "exact Rust process-kill and cleanup oracles, Python legacy-owner oracle, and WebView boundary test"
assertions = (
"five migration persistence boundaries survive twenty hard process terminations each",
"every recovery exposes a complete old or new state and preserves three exact records without duplicates",
"legacy source, managed key, and encrypted backup remain until an exact native-confirmed source hash is supplied",
"confirmed cleanup resumes across six boundaries, removes managed artifacts, and leaves an external environment key unchanged",
"the old Python credential store rejects reads and writes after desktop ownership switches",
"the WebView supplies neither a cleanup path nor confirmation and only invokes the native Host command",
)
payload = {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": "" if passed else "A B-04 transactional migration, cleanup, legacy-owner, or UI boundary oracle failed.",
"assertions": [
{"name": name, "status": status, "evidence": evidence} for name in assertions
],
"metrics": {
"migration_boundaries": 5 if passed else 0,
"hard_terminations": 100 if passed else 0,
"recovered_records": 300 if passed else 0,
"duplicate_records": 0,
"cleanup_boundaries": 6 if passed else 0,
"managed_artifacts_remaining": 0 if passed else None,
"external_key_changes": 0 if passed else None,
"old_writer_rejections": 4 if passed else 0,
"native_confirmation_boundary": 1 if passed else 0,
},
"files": [
{"path": relative, "sha256": sha256(ROOT / relative)}
for relative in (
"frontend/src-tauri/src/credentials.rs",
"frontend/src-tauri/src/main.rs",
"frontend/src/features/settings/CredentialVaultSettings.vue",
"frontend/src/features/settings/CredentialVaultSettings.spec.ts",
"backend/app/providers/credentials.py",
"backend/tests/test_credentials.py",
"scripts/acceptance_cases/b04_credentials.py",
)
],
"revisions": [
{
"scope": "migration",
"states": ["backed_up", "copied", "verified", "switched"],
"ownership_commit": "source .opennexus-owner.json",
},
{
"scope": "cleanup",
"states": ["cleanup_authorized", "cleanup_confirmed"],
"external_environment_key_managed": False,
},
],
}
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())
+16
View File
@@ -65,6 +65,22 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
),
"platform_profiles": ("windows-11-x64",),
},
"B-04": {
"driver": "scripts/acceptance_cases/b04_credentials.py",
"timeout_seconds": 1800,
"required_metrics": (
"migration_boundaries",
"hard_terminations",
"recovered_records",
"duplicate_records",
"cleanup_boundaries",
"managed_artifacts_remaining",
"external_key_changes",
"old_writer_rejections",
"native_confirmation_boundary",
),
"platform_profiles": ("windows-11-x64",),
},
"C-03": {
"driver": "scripts/acceptance_cases/c03_permission_binding.py",
"timeout_seconds": 900,