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>