diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index 975f3b0..bbbc25a 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -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 { + 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 { + 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 { + 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>> { + 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, @@ -219,6 +362,14 @@ impl CredentialBroker { &mut self, directory: &Path, environment_key: Option>, + ) -> Result { + self.import_fernet_inner(directory, environment_key, |_| Ok(())) + } + fn import_fernet_inner( + &mut self, + directory: &Path, + environment_key: Option>, + mut checkpoint: impl FnMut(&str) -> Result<()>, ) -> Result { 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 = @@ -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 { + 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 { let method = request["rpc"].as_str().ok_or("HOST_REQUEST_INVALID")?; let params = &request["params"]; @@ -734,6 +1049,32 @@ mod tests { fn password() -> Zeroizing> { Zeroizing::new(b"test-only-password-123".to_vec()) } + fn b04_fixture() -> (Vec, String, BTreeMap) { + 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"); diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index ccd1f6a..6109fa3 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -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, Stri .map_err(|_| "HOST_BUSY")? } +#[derive(Serialize)] +struct CredentialCleanupResult { + count: usize, + already_clean: bool, +} + +#[tauri::command] +async fn credentials_cleanup( + host: State<'_, Host>, +) -> Result, 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, diff --git a/frontend/src/features/settings/CredentialVaultSettings.spec.ts b/frontend/src/features/settings/CredentialVaultSettings.spec.ts index 80a8c70..4d83fb8 100644 --- a/frontend/src/features/settings/CredentialVaultSettings.spec.ts +++ b/frontend/src/features/settings/CredentialVaultSettings.spec.ts @@ -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() +}) diff --git a/frontend/src/features/settings/CredentialVaultSettings.vue b/frontend/src/features/settings/CredentialVaultSettings.vue index 4842b64..3c63018 100644 --- a/frontend/src/features/settings/CredentialVaultSettings.vue +++ b/frontend/src/features/settings/CredentialVaultSettings.vue @@ -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)) + diff --git a/scripts/acceptance_cases/b04_credentials.py b/scripts/acceptance_cases/b04_credentials.py new file mode 100644 index 0000000..bab093e --- /dev/null +++ b/scripts/acceptance_cases/b04_credentials.py @@ -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()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 978797a..969dbdd 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -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,