From af0d66d3703d7b62d4d2ba3d9f3736f17426b7ee Mon Sep 17 00:00:00 2001 From: Continuist Date: Fri, 17 Oct 2025 21:38:08 -0400 Subject: [PATCH] Add universe binding and user profiles to passport --- Cargo.lock | 147 +++- libs/sharenet-passport/Cargo.toml | 2 + .../src/application/use_cases.rs | 221 +++++- .../src/application/use_cases_test.rs | 633 +++++++++++++++++- libs/sharenet-passport/src/domain/entities.rs | 192 +++++- .../src/domain/entities_test.rs | 199 +++++- libs/sharenet-passport/src/domain/traits.rs | 6 +- .../src/infrastructure/crypto.rs | 30 +- .../src/infrastructure/crypto_test.rs | 133 +++- .../src/infrastructure/storage_test.rs | 2 + .../src/universe_binding_test.rs | 232 +++++++ sharenet-passport-cli/Cargo.toml | 1 + .../src/bin/test_universe_binding.rs | 105 +++ sharenet-passport-cli/src/cli/commands.rs | 153 ++++- sharenet-passport-cli/src/cli/interface.rs | 302 ++++++++- sharenet-passport-cli/src/main.rs | 85 ++- src/bin/test_universe_binding.rs | 105 +++ 17 files changed, 2494 insertions(+), 54 deletions(-) create mode 100644 libs/sharenet-passport/src/universe_binding_test.rs create mode 100644 sharenet-passport-cli/src/bin/test_universe_binding.rs create mode 100644 src/bin/test_universe_binding.rs diff --git a/Cargo.lock b/Cargo.lock index 8ba2074..2386ad5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "cfg-if" version = "1.0.3" @@ -146,6 +152,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half 2.7.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -218,6 +251,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -363,6 +402,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "heck" version = "0.5.0" @@ -414,6 +464,16 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.176" @@ -426,6 +486,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + [[package]] name = "once_cell" version = "1.21.3" @@ -571,6 +637,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "semver" version = "1.0.27" @@ -593,7 +665,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" dependencies = [ - "half", + "half 1.8.3", "serde", ] @@ -634,6 +706,7 @@ version = "0.1.0" dependencies = [ "bip39", "chacha20poly1305", + "ciborium", "ed25519-dalek", "hex", "hkdf", @@ -644,6 +717,7 @@ dependencies = [ "sha2", "tempfile", "thiserror", + "uuid", "zeroize", ] @@ -657,6 +731,7 @@ dependencies = [ "rpassword", "sharenet-passport", "tempfile", + "uuid", ] [[package]] @@ -786,6 +861,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -816,6 +902,65 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.0" diff --git a/libs/sharenet-passport/Cargo.toml b/libs/sharenet-passport/Cargo.toml index 3a69e78..bf348f4 100644 --- a/libs/sharenet-passport/Cargo.toml +++ b/libs/sharenet-passport/Cargo.toml @@ -24,6 +24,8 @@ serde_cbor = "0.11" thiserror = "1.0" zeroize = { version = "1.7", features = ["zeroize_derive"] } hex = "0.4" +ciborium = "0.2" +uuid = { version = "1.8", features = ["v7"] } [lib] crate-type = ["cdylib", "rlib"] # Support both native and WASM diff --git a/libs/sharenet-passport/src/application/use_cases.rs b/libs/sharenet-passport/src/application/use_cases.rs index adc3fe7..a9cde6e 100644 --- a/libs/sharenet-passport/src/application/use_cases.rs +++ b/libs/sharenet-passport/src/application/use_cases.rs @@ -2,6 +2,7 @@ use crate::domain::entities::*; use crate::domain::traits::*; use crate::application::error::ApplicationError; use ed25519_dalek::Signer; +use std::time::{SystemTime, UNIX_EPOCH}; pub struct CreatePassportUseCase where @@ -39,6 +40,7 @@ where pub fn execute( &self, + univ_id: &str, password: &str, output_path: &str, ) -> Result<(Passport, RecoveryPhrase), ApplicationError> { @@ -48,10 +50,10 @@ where .generate() .map_err(|e| ApplicationError::UseCaseError(format!("Failed to generate mnemonic: {}", e.into())))?; - // Derive seed from mnemonic + // Derive seed from mnemonic and universe let seed = self .key_deriver - .derive_from_mnemonic(&recovery_phrase) + .derive_from_mnemonic(&recovery_phrase, univ_id) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to derive seed: {}", e.into())))?; // Derive keys from seed @@ -65,6 +67,7 @@ where seed, public_key, private_key, + univ_id.to_string(), ); // Encrypt and save file @@ -75,6 +78,8 @@ where password, &passport.public_key, &passport.did, + &passport.univ_id, + &passport.user_profiles, ) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; @@ -122,6 +127,7 @@ where pub fn execute( &self, + univ_id: &str, recovery_words: &[String], password: &str, output_path: &str, @@ -133,10 +139,10 @@ where let recovery_phrase = RecoveryPhrase::new(recovery_words.to_vec()); - // Derive seed from mnemonic + // Derive seed from mnemonic and universe let seed = self .key_deriver - .derive_from_mnemonic(&recovery_phrase) + .derive_from_mnemonic(&recovery_phrase, univ_id) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to derive seed: {}", e.into())))?; // Derive keys from seed @@ -150,6 +156,7 @@ where seed, public_key, private_key, + univ_id.to_string(), ); // Encrypt and save file @@ -160,6 +167,8 @@ where password, &passport.public_key, &passport.did, + &passport.univ_id, + &passport.user_profiles, ) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; @@ -208,18 +217,20 @@ where .map_err(|e| ApplicationError::UseCaseError(format!("Failed to load file: {}", e.into())))?; // Decrypt file - let (seed, public_key, private_key) = self + let (seed, public_key, private_key, user_profiles) = self .file_encryptor .decrypt(&passport_file, password) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to decrypt file: {}", e.into())))?; // Create passport (without storing recovery phrase) - let passport = Passport::new( + let mut passport = Passport::new( seed, public_key, private_key, + passport_file.univ_id.clone(), ); + passport.user_profiles = user_profiles; // Re-encrypt and save if output path provided if let Some(output_path) = output_path { @@ -230,6 +241,8 @@ where password, &passport.public_key, &passport.did, + &passport.univ_id, + &passport.user_profiles, ) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to re-encrypt file: {}", e.into())))?; @@ -276,6 +289,8 @@ where password, &passport.public_key, &passport.did, + &passport.univ_id, + &passport.user_profiles, ) .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; @@ -306,10 +321,200 @@ impl SignCardUseCase { .map_err(|_| ApplicationError::UseCaseError("Invalid private key length".to_string()))? ); - // Sign the message - let signature = signing_key.sign(message.as_bytes()); + // Create universe-bound message to sign + let message_to_sign = format!("u:{}:{}", passport.univ_id, message); + + // Sign the universe-bound message + let signature = signing_key.sign(message_to_sign.as_bytes()); // Return the signature as bytes Ok(signature.to_bytes().to_vec()) } +} + +pub struct CreateUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + file_encryptor: FE, + file_storage: FS, +} + +impl CreateUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + pub fn new(file_encryptor: FE, file_storage: FS) -> Self { + Self { + file_encryptor, + file_storage, + } + } + + pub fn execute( + &self, + passport: &mut Passport, + hub_did: Option, + identity: UserIdentity, + preferences: UserPreferences, + password: &str, + file_path: &str, + ) -> Result<(), ApplicationError> { + let profile = UserProfile::new(hub_did, identity, preferences); + + passport.add_user_profile(profile) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to add user profile: {}", e)))?; + + // Save updated passport + let passport_file = self + .file_encryptor + .encrypt( + &passport.seed, + password, + &passport.public_key, + &passport.did, + &passport.univ_id, + &passport.user_profiles, + ) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; + + self.file_storage + .save(&passport_file, file_path) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?; + + Ok(()) + } +} + +pub struct UpdateUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + file_encryptor: FE, + file_storage: FS, +} + +impl UpdateUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + pub fn new(file_encryptor: FE, file_storage: FS) -> Self { + Self { + file_encryptor, + file_storage, + } + } + + pub fn execute( + &self, + passport: &mut Passport, + id: Option<&str>, + identity: UserIdentity, + preferences: UserPreferences, + password: &str, + file_path: &str, + ) -> Result<(), ApplicationError> { + // Find existing profile by ID to preserve its ID and created_at + let id = id + .ok_or_else(|| ApplicationError::UseCaseError("Profile ID is required".to_string()))?; + + let existing_profile = passport.user_profile_by_id(id) + .ok_or_else(|| ApplicationError::UseCaseError("User profile not found".to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| ApplicationError::UseCaseError(format!("Time error: {}", e)))? + .as_secs(); + + // Use existing hub_did (cannot change hub_did via update) + let profile = UserProfile { + id: existing_profile.id.clone(), + hub_did: existing_profile.hub_did.clone(), + identity, + preferences, + created_at: existing_profile.created_at, + updated_at: now, + }; + + passport.update_user_profile_by_id(id, profile) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to update user profile: {}", e)))?; + + // Save updated passport + let passport_file = self + .file_encryptor + .encrypt( + &passport.seed, + password, + &passport.public_key, + &passport.did, + &passport.univ_id, + &passport.user_profiles, + ) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; + + self.file_storage + .save(&passport_file, file_path) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?; + + Ok(()) + } +} + +pub struct DeleteUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + file_encryptor: FE, + file_storage: FS, +} + +impl DeleteUserProfileUseCase +where + FE: FileEncryptor, + FS: FileStorage, +{ + pub fn new(file_encryptor: FE, file_storage: FS) -> Self { + Self { + file_encryptor, + file_storage, + } + } + + pub fn execute( + &self, + passport: &mut Passport, + id: Option<&str>, + password: &str, + file_path: &str, + ) -> Result<(), ApplicationError> { + let id = id + .ok_or_else(|| ApplicationError::UseCaseError("Profile ID is required".to_string()))?; + + passport.remove_user_profile_by_id(id) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to remove user profile: {}", e)))?; + + // Save updated passport + let passport_file = self + .file_encryptor + .encrypt( + &passport.seed, + password, + &passport.public_key, + &passport.did, + &passport.univ_id, + &passport.user_profiles, + ) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?; + + self.file_storage + .save(&passport_file, file_path) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?; + + Ok(()) + } } \ No newline at end of file diff --git a/libs/sharenet-passport/src/application/use_cases_test.rs b/libs/sharenet-passport/src/application/use_cases_test.rs index 01350aa..c6295c1 100644 --- a/libs/sharenet-passport/src/application/use_cases_test.rs +++ b/libs/sharenet-passport/src/application/use_cases_test.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::application::use_cases::{CreatePassportUseCase, ImportFromRecoveryUseCase, ImportFromFileUseCase, ExportPassportUseCase, SignCardUseCase}; + use crate::application::use_cases::{CreatePassportUseCase, ImportFromRecoveryUseCase, ImportFromFileUseCase, ExportPassportUseCase, SignCardUseCase, CreateUserProfileUseCase, UpdateUserProfileUseCase, DeleteUserProfileUseCase}; // Note: These domain entities are used indirectly through the use cases use crate::infrastructure::crypto::{Bip39MnemonicGenerator, Ed25519KeyDeriver, XChaCha20FileEncryptor}; use crate::infrastructure::storage::FileSystemStorage; @@ -19,12 +19,15 @@ mod tests { let file_path = temp_file.path().to_str().unwrap(); let password = "test-password"; - let (passport, recovery_phrase) = use_case.execute(password, file_path).unwrap(); + let (passport, recovery_phrase) = use_case.execute("univ:test:create", password, file_path).unwrap(); // Verify passport structure assert_eq!(recovery_phrase.words().len(), 24); assert_eq!(passport.public_key().0.len(), 32); - assert!(passport.did().as_str().starts_with("did:sharenet:")); + // DID should have "p:" prefix followed by hex-encoded public key (66 characters for 32 bytes + "p:") + assert_eq!(passport.did().as_str().len(), 66); + assert!(passport.did().as_str().starts_with("p:")); + assert!(passport.did().as_str().chars().skip(2).all(|c| c.is_ascii_hexdigit())); // Verify file was created assert!(std::path::Path::new(file_path).exists()); @@ -44,7 +47,7 @@ mod tests { let file_path1 = temp_file1.path().to_str().unwrap(); let password = "test-password"; - let (passport, recovery_phrase) = create_use_case.execute(password, file_path1).unwrap(); + let (passport, recovery_phrase) = create_use_case.execute("univ:test:create", password, file_path1).unwrap(); let original_did = passport.did().as_str().to_string(); // Now import from the recovery phrase @@ -59,6 +62,7 @@ mod tests { let file_path2 = temp_file2.path().to_str().unwrap(); let imported_passport = import_use_case.execute( + "univ:test:create", recovery_phrase.words(), password, file_path2, @@ -83,7 +87,7 @@ mod tests { let file_path1 = temp_file1.path().to_str().unwrap(); let password = "test-password"; - let (passport, _) = create_use_case.execute(password, file_path1).unwrap(); + let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap(); let original_did = passport.did().as_str().to_string(); // Now import from the file @@ -117,7 +121,7 @@ mod tests { let file_path1 = temp_file1.path().to_str().unwrap(); let password = "test-password"; - let (passport, _) = create_use_case.execute(password, file_path1).unwrap(); + let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap(); let original_did = passport.did().as_str().to_string(); // Now import and re-encrypt to a new file @@ -157,7 +161,7 @@ mod tests { let file_path1 = temp_file1.path().to_str().unwrap(); let password = "test-password"; - let (passport, _) = create_use_case.execute(password, file_path1).unwrap(); + let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap(); let original_did = passport.did().as_str().to_string(); // Now export with a new password @@ -204,7 +208,7 @@ mod tests { let file_path = temp_file.path().to_str().unwrap(); let password = "test-password"; - create_use_case.execute(password, file_path).unwrap(); + create_use_case.execute("univ:test:create", password, file_path).unwrap(); // Try to import with wrong password let import_use_case = ImportFromFileUseCase::new( @@ -237,7 +241,7 @@ mod tests { let invalid_words = vec!["invalid".to_string(); 24]; - let result = use_case.execute(&invalid_words, password, file_path); + let result = use_case.execute("univ:test:create", &invalid_words, password, file_path); // Should fail due to invalid mnemonic assert!(result.is_err()); @@ -259,7 +263,7 @@ mod tests { let file_path = temp_file.path().to_str().unwrap(); let password = "test-password"; - let (passport, _) = create_use_case.execute(password, file_path).unwrap(); + let (passport, _) = create_use_case.execute("univ:test:create", password, file_path).unwrap(); // Sign a test message let test_message = "Hello, Sharenet!"; @@ -273,8 +277,10 @@ mod tests { &passport.public_key().0[..].try_into().unwrap() ).unwrap(); + // Create the universe-bound message that was actually signed + let universe_bound_message = format!("u:{}:{}", passport.univ_id(), test_message); let signature_obj = ed25519_dalek::Signature::from_bytes(&signature.try_into().unwrap()); - let verification_result = verifying_key.verify_strict(test_message.as_bytes(), &signature_obj); + let verification_result = verifying_key.verify_strict(universe_bound_message.as_bytes(), &signature_obj); assert!(verification_result.is_ok()); } @@ -295,7 +301,7 @@ mod tests { let file_path = temp_file.path().to_str().unwrap(); let password = "test-password"; - let (passport, _) = create_use_case.execute(password, file_path).unwrap(); + let (passport, _) = create_use_case.execute("univ:test:create", password, file_path).unwrap(); // Sign two different messages let message1 = "Message 1"; @@ -311,4 +317,607 @@ mod tests { assert_eq!(signature1.len(), 64); assert_eq!(signature2.len(), 64); } + + #[test] + fn test_user_profile_management_integration() { + use crate::application::use_cases::{CreateUserProfileUseCase, UpdateUserProfileUseCase, DeleteUserProfileUseCase}; + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:profiles", password, file_path).unwrap(); + + // Verify default profile exists + assert!(passport.default_user_profile().is_some()); + assert_eq!(passport.user_profiles().len(), 1); + + // Create user profile use cases + let create_profile_use_case = CreateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let update_profile_use_case = UpdateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let delete_profile_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Create a hub-specific profile + let hub_identity = UserIdentity { + handle: Some("hubuser".to_string()), + display_name: Some("Hub User".to_string()), + first_name: Some("Hub".to_string()), + last_name: Some("User".to_string()), + email: Some("hub@example.com".to_string()), + avatar_url: Some("https://example.com/avatar.png".to_string()), + bio: Some("Hub user bio".to_string()), + }; + + let hub_preferences = UserPreferences { + theme: Some("light".to_string()), + language: Some("fr".to_string()), + notifications_enabled: false, + auto_sync: true, + }; + + create_profile_use_case.execute( + &mut passport, + Some("h:hub1".to_string()), + hub_identity, + hub_preferences, + password, + file_path, + ).unwrap(); + + // Verify profile was added + assert_eq!(passport.user_profiles().len(), 2); + let hub_profile = passport.user_profile_for_hub("h:hub1").unwrap(); + assert_eq!(hub_profile.identity.handle, Some("hubuser".to_string())); + assert_eq!(hub_profile.identity.display_name, Some("Hub User".to_string())); + assert_eq!(hub_profile.preferences.language, Some("fr".to_string())); + + // Update the hub profile + let updated_identity = UserIdentity { + handle: Some("updatedhubuser".to_string()), + display_name: Some("Updated Hub User".to_string()), + first_name: Some("Updated".to_string()), + last_name: Some("Hub User".to_string()), + email: Some("updated@example.com".to_string()), + avatar_url: Some("https://example.com/new-avatar.png".to_string()), + bio: Some("Updated bio".to_string()), + }; + + let updated_preferences = UserPreferences { + theme: Some("dark".to_string()), + language: Some("en".to_string()), + notifications_enabled: true, + auto_sync: false, + }; + + // Get the profile ID for the hub profile + let profile_id = passport.user_profile_for_hub("h:hub1").unwrap().id.clone(); + + update_profile_use_case.execute( + &mut passport, + Some(&profile_id), + updated_identity, + updated_preferences, + password, + file_path, + ).unwrap(); + + // Verify profile was updated + let updated_profile = passport.user_profile_for_hub("h:hub1").unwrap(); + assert_eq!(updated_profile.identity.handle, Some("updatedhubuser".to_string())); + assert_eq!(updated_profile.identity.display_name, Some("Updated Hub User".to_string())); + assert_eq!(updated_profile.preferences.theme, Some("dark".to_string())); + assert_eq!(updated_profile.preferences.language, Some("en".to_string())); + + // Delete the hub profile + let profile_id = passport.user_profile_for_hub("h:hub1").unwrap().id.clone(); + + delete_profile_use_case.execute( + &mut passport, + Some(&profile_id), + password, + file_path, + ).unwrap(); + + // Verify profile was removed + assert_eq!(passport.user_profiles().len(), 1); + assert!(passport.user_profile_for_hub("h:hub1").is_none()); + + // Verify default profile still exists + assert!(passport.default_user_profile().is_some()); + } + + #[test] + fn test_user_profile_persistence_across_import() { + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport with user profiles + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:persistence", password, file_path).unwrap(); + + // Add a hub profile + let create_profile_use_case = CreateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let hub_identity = UserIdentity { + handle: Some("persistentuser".to_string()), + display_name: Some("Persistent User".to_string()), + first_name: Some("Persistent".to_string()), + last_name: Some("User".to_string()), + email: Some("persistent@example.com".to_string()), + avatar_url: None, + bio: Some("This should persist".to_string()), + }; + + let hub_preferences = UserPreferences { + theme: Some("auto".to_string()), + language: Some("es".to_string()), + notifications_enabled: true, + auto_sync: true, + }; + + create_profile_use_case.execute( + &mut passport, + Some("h:persistence".to_string()), + hub_identity, + hub_preferences, + password, + file_path, + ).unwrap(); + + // Now import from the file + let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let imported_passport = import_use_case.execute( + file_path, + password, + None, + ).unwrap(); + + // Verify user profiles persisted + assert_eq!(imported_passport.user_profiles().len(), 2); + + // Verify default profile + assert!(imported_passport.default_user_profile().is_some()); + + // Verify hub profile + let imported_hub_profile = imported_passport.user_profile_for_hub("h:persistence").unwrap(); + assert_eq!(imported_hub_profile.identity.handle, Some("persistentuser".to_string())); + assert_eq!(imported_hub_profile.identity.display_name, Some("Persistent User".to_string())); + assert_eq!(imported_hub_profile.identity.email, Some("persistent@example.com".to_string())); + assert_eq!(imported_hub_profile.preferences.language, Some("es".to_string())); + assert_eq!(imported_hub_profile.preferences.theme, Some("auto".to_string())); + } + + #[test] + fn test_update_user_profile_use_case() { + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:update", password, file_path).unwrap(); + + // Add a hub profile to update + let create_profile_use_case = CreateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let original_identity = UserIdentity { + handle: Some("originaluser".to_string()), + display_name: Some("Original User".to_string()), + first_name: Some("Original".to_string()), + last_name: Some("User".to_string()), + email: Some("original@example.com".to_string()), + avatar_url: None, + bio: Some("Original bio".to_string()), + }; + + let original_preferences = UserPreferences { + theme: Some("light".to_string()), + language: Some("en".to_string()), + notifications_enabled: false, + auto_sync: false, + }; + + create_profile_use_case.execute( + &mut passport, + Some("h:update".to_string()), + original_identity, + original_preferences, + password, + file_path, + ).unwrap(); + + // Get the profile ID + let profile_id = passport.user_profile_for_hub("h:update").unwrap().id.clone(); + + // Create update use case + let update_profile_use_case = UpdateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Update the profile + let updated_identity = UserIdentity { + handle: Some("updateduser".to_string()), + display_name: Some("Updated User".to_string()), + first_name: Some("Updated".to_string()), + last_name: Some("User".to_string()), + email: Some("updated@example.com".to_string()), + avatar_url: Some("https://example.com/new-avatar.png".to_string()), + bio: Some("Updated bio".to_string()), + }; + + let updated_preferences = UserPreferences { + theme: Some("dark".to_string()), + language: Some("fr".to_string()), + notifications_enabled: true, + auto_sync: true, + }; + + update_profile_use_case.execute( + &mut passport, + Some(&profile_id), + updated_identity, + updated_preferences, + password, + file_path, + ).unwrap(); + + // Verify the profile was updated + let updated_profile = passport.user_profile_for_hub("h:update").unwrap(); + assert_eq!(updated_profile.identity.handle, Some("updateduser".to_string())); + assert_eq!(updated_profile.identity.display_name, Some("Updated User".to_string())); + assert_eq!(updated_profile.identity.email, Some("updated@example.com".to_string())); + assert_eq!(updated_profile.preferences.theme, Some("dark".to_string())); + assert_eq!(updated_profile.preferences.language, Some("fr".to_string())); + assert!(updated_profile.preferences.notifications_enabled); + assert!(updated_profile.preferences.auto_sync); + + // Verify the ID and hub_did remain the same + assert_eq!(updated_profile.id, profile_id); + assert_eq!(updated_profile.hub_did, Some("h:update".to_string())); + + // Verify updated_at timestamp changed + assert!(updated_profile.updated_at > 0); + } + + #[test] + fn test_update_user_profile_use_case_invalid_id() { + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:update_invalid", password, file_path).unwrap(); + + // Create update use case + let update_profile_use_case = UpdateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Try to update with invalid ID + let identity = UserIdentity { + handle: Some("test".to_string()), + display_name: Some("Test".to_string()), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + email: Some("test@example.com".to_string()), + avatar_url: None, + bio: None, + }; + + let preferences = UserPreferences { + theme: Some("dark".to_string()), + language: Some("en".to_string()), + notifications_enabled: true, + auto_sync: false, + }; + + let result = update_profile_use_case.execute( + &mut passport, + Some("invalid-uuid"), + identity, + preferences, + password, + file_path, + ); + + // Should fail with profile not found + assert!(result.is_err()); + } + + #[test] + fn test_update_user_profile_use_case_missing_id() { + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:update_missing", password, file_path).unwrap(); + + // Create update use case + let update_profile_use_case = UpdateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Try to update with missing ID + let identity = UserIdentity { + handle: Some("test".to_string()), + display_name: Some("Test".to_string()), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + email: Some("test@example.com".to_string()), + avatar_url: None, + bio: None, + }; + + let preferences = UserPreferences { + theme: Some("dark".to_string()), + language: Some("en".to_string()), + notifications_enabled: true, + auto_sync: false, + }; + + let result = update_profile_use_case.execute( + &mut passport, + None, + identity, + preferences, + password, + file_path, + ); + + // Should fail with ID required + assert!(result.is_err()); + } + + #[test] + fn test_delete_user_profile_use_case() { + use crate::domain::entities::{UserIdentity, UserPreferences}; + + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:delete", password, file_path).unwrap(); + + // Add a hub profile to delete + let create_profile_use_case = CreateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let identity = UserIdentity { + handle: Some("todelete".to_string()), + display_name: Some("To Delete".to_string()), + first_name: Some("To".to_string()), + last_name: Some("Delete".to_string()), + email: Some("delete@example.com".to_string()), + avatar_url: None, + bio: Some("This will be deleted".to_string()), + }; + + let preferences = UserPreferences { + theme: Some("light".to_string()), + language: Some("en".to_string()), + notifications_enabled: false, + auto_sync: false, + }; + + create_profile_use_case.execute( + &mut passport, + Some("h:delete".to_string()), + identity, + preferences, + password, + file_path, + ).unwrap(); + + // Verify profile was added + assert_eq!(passport.user_profiles().len(), 2); + assert!(passport.user_profile_for_hub("h:delete").is_some()); + + // Get the profile ID + let profile_id = passport.user_profile_for_hub("h:delete").unwrap().id.clone(); + + // Create delete use case + let delete_profile_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Delete the profile + delete_profile_use_case.execute( + &mut passport, + Some(&profile_id), + password, + file_path, + ).unwrap(); + + // Verify profile was deleted + assert_eq!(passport.user_profiles().len(), 1); + assert!(passport.user_profile_for_hub("h:delete").is_none()); + + // Verify default profile still exists + assert!(passport.default_user_profile().is_some()); + } + + #[test] + fn test_delete_user_profile_use_case_invalid_id() { + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:delete_invalid", password, file_path).unwrap(); + + // Create delete use case + let delete_profile_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Try to delete with invalid ID + let result = delete_profile_use_case.execute( + &mut passport, + Some("invalid-uuid"), + password, + file_path, + ); + + // Should fail with profile not found + assert!(result.is_err()); + } + + #[test] + fn test_delete_user_profile_use_case_missing_id() { + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:delete_missing", password, file_path).unwrap(); + + // Create delete use case + let delete_profile_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Try to delete with missing ID + let result = delete_profile_use_case.execute( + &mut passport, + None, + password, + file_path, + ); + + // Should fail with ID required + assert!(result.is_err()); + } + + #[test] + fn test_delete_user_profile_use_case_cannot_delete_default() { + // First create a passport + let create_use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_str().unwrap(); + let password = "test-password"; + + let (mut passport, _) = create_use_case.execute("univ:test:delete_default", password, file_path).unwrap(); + + // Get the default profile ID + let default_profile_id = passport.default_user_profile().unwrap().id.clone(); + + // Create delete use case + let delete_profile_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + // Try to delete the default profile + let result = delete_profile_use_case.execute( + &mut passport, + Some(&default_profile_id), + password, + file_path, + ); + + // Should fail - cannot delete default profile + assert!(result.is_err()); + } } \ No newline at end of file diff --git a/libs/sharenet-passport/src/domain/entities.rs b/libs/sharenet-passport/src/domain/entities.rs index 70fd7a1..bfe23ee 100644 --- a/libs/sharenet-passport/src/domain/entities.rs +++ b/libs/sharenet-passport/src/domain/entities.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; use zeroize::{Zeroize, ZeroizeOnDrop}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -43,8 +45,8 @@ pub struct Did(pub String); impl Did { pub fn new(public_key: &PublicKey) -> Self { - // Simple DID format for now - in production this would use proper DID method - let did_str = format!("did:sharenet:{}", hex::encode(&public_key.0)); + // Passport DID format with "p:" prefix + let did_str = format!("p:{}", hex::encode(&public_key.0)); Self(did_str) } @@ -74,6 +76,8 @@ pub struct Passport { pub public_key: PublicKey, pub private_key: PrivateKey, pub did: Did, + pub univ_id: String, + pub user_profiles: Vec, } impl Passport { @@ -81,13 +85,37 @@ impl Passport { seed: Seed, public_key: PublicKey, private_key: PrivateKey, + univ_id: String, ) -> Self { let did = Did::new(&public_key); + + // Create default user profile + let default_profile = UserProfile::new( + None, + UserIdentity { + handle: None, + display_name: None, + first_name: None, + last_name: None, + email: None, + avatar_url: None, + bio: None, + }, + UserPreferences { + theme: None, + language: None, + notifications_enabled: true, + auto_sync: true, + }, + ); + Self { seed, public_key, private_key, did, + univ_id, + user_profiles: vec![default_profile], } } @@ -98,6 +126,164 @@ impl Passport { pub fn did(&self) -> &Did { &self.did } + + pub fn univ_id(&self) -> &str { + &self.univ_id + } + + pub fn user_profiles(&self) -> &[UserProfile] { + &self.user_profiles + } + + pub fn default_user_profile(&self) -> Option<&UserProfile> { + self.user_profiles.iter().find(|p| p.is_default()) + } + + pub fn user_profile_for_hub(&self, hub_did: &str) -> Option<&UserProfile> { + self.user_profiles.iter().find(|p| p.hub_did.as_deref() == Some(hub_did)) + } + + pub fn user_profile_by_id(&self, profile_id: &str) -> Option<&UserProfile> { + self.user_profiles.iter().find(|p| p.id == profile_id) + } + + pub fn user_profile_by_id_mut(&mut self, profile_id: &str) -> Option<&mut UserProfile> { + self.user_profiles.iter_mut().find(|p| p.id == profile_id) + } + + pub fn add_user_profile(&mut self, profile: UserProfile) -> Result<(), String> { + // Ensure only one default profile + if profile.is_default() && self.default_user_profile().is_some() { + return Err("Default user profile already exists".to_string()); + } + + // Ensure hub_did is unique + if let Some(hub_did) = &profile.hub_did { + if self.user_profile_for_hub(hub_did).is_some() { + return Err(format!("User profile for hub DID {} already exists", hub_did)); + } + } + + self.user_profiles.push(profile); + Ok(()) + } + + pub fn update_user_profile(&mut self, hub_did: Option<&str>, profile: UserProfile) -> Result<(), String> { + let index = self.user_profiles.iter().position(|p| { + match (p.hub_did.as_deref(), hub_did) { + (None, None) => true, // Default profile + (Some(p_hub), Some(hub)) if p_hub == hub => true, // Hub-specific profile + _ => false, + } + }); + + match index { + Some(idx) => { + self.user_profiles[idx] = profile; + Ok(()) + } + None => Err("User profile not found".to_string()), + } + } + + pub fn remove_user_profile(&mut self, hub_did: Option<&str>) -> Result<(), String> { + if hub_did.is_none() { + return Err("Cannot delete default user profile".to_string()); + } + + let index = self.user_profiles.iter().position(|p| p.hub_did.as_deref() == hub_did); + + match index { + Some(idx) => { + self.user_profiles.remove(idx); + Ok(()) + } + None => Err("User profile not found".to_string()), + } + } + + pub fn update_user_profile_by_id(&mut self, profile_id: &str, profile: UserProfile) -> Result<(), String> { + let index = self.user_profiles.iter().position(|p| p.id == profile_id); + + match index { + Some(idx) => { + self.user_profiles[idx] = profile; + Ok(()) + } + None => Err("User profile not found".to_string()), + } + } + + pub fn remove_user_profile_by_id(&mut self, profile_id: &str) -> Result<(), String> { + let index = self.user_profiles.iter().position(|p| p.id == profile_id); + + match index { + Some(idx) => { + // Check if this is the default profile + if self.user_profiles[idx].is_default() { + return Err("Cannot delete default user profile".to_string()); + } + self.user_profiles.remove(idx); + Ok(()) + } + None => Err("User profile not found".to_string()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserIdentity { + pub handle: Option, + pub display_name: Option, + pub first_name: Option, + pub last_name: Option, + pub email: Option, + pub avatar_url: Option, + pub bio: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserPreferences { + pub theme: Option, + pub language: Option, + pub notifications_enabled: bool, + pub auto_sync: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfile { + pub id: String, // UUIDv7 unique identifier for the profile + pub hub_did: Option, // None for default profile + pub identity: UserIdentity, + pub preferences: UserPreferences, + pub created_at: u64, + pub updated_at: u64, +} + +impl UserProfile { + pub fn new( + hub_did: Option, + identity: UserIdentity, + preferences: UserPreferences, + ) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Self { + id: Uuid::now_v7().to_string(), + hub_did, + identity, + preferences, + created_at: now, + updated_at: now, + } + } + + pub fn is_default(&self) -> bool { + self.hub_did.is_none() + } } #[derive(Debug, Serialize, Deserialize)] @@ -109,6 +295,8 @@ pub struct PassportFile { pub nonce: Vec, pub public_key: Vec, pub did: String, + pub univ_id: String, pub created_at: u64, pub version: String, + pub enc_user_profiles: Vec, // Encrypted CBOR of Vec } \ No newline at end of file diff --git a/libs/sharenet-passport/src/domain/entities_test.rs b/libs/sharenet-passport/src/domain/entities_test.rs index 17b84c8..9e772a4 100644 --- a/libs/sharenet-passport/src/domain/entities_test.rs +++ b/libs/sharenet-passport/src/domain/entities_test.rs @@ -21,8 +21,13 @@ mod tests { let public_key = PublicKey(vec![1, 2, 3, 4, 5]); let did = Did::new(&public_key); - assert!(did.as_str().starts_with("did:sharenet:")); - assert!(did.as_str().contains(&hex::encode(&public_key.0))); + // DID should have "p:" prefix followed by hex-encoded public key + let expected_did = format!("p:{}", hex::encode(&public_key.0)); + assert_eq!(did.as_str(), expected_did); + // For a 5-byte public key, hex encoding should be 10 characters + "p:" prefix = 12 characters + assert_eq!(did.as_str().len(), 12); + assert!(did.as_str().starts_with("p:")); + assert!(did.as_str().chars().skip(2).all(|c| c.is_ascii_hexdigit())); } #[test] @@ -50,4 +55,194 @@ mod tests { // After zeroization, bytes should be empty (zeroize clears the vector) assert_eq!(private_key.0, vec![]); } + + #[test] + fn test_user_profile_creation() { + use crate::domain::entities::{UserIdentity, UserPreferences, UserProfile}; + + let identity = UserIdentity { + handle: Some("testuser".to_string()), + display_name: Some("Test User".to_string()), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + email: Some("test@example.com".to_string()), + avatar_url: Some("https://example.com/avatar.png".to_string()), + bio: Some("Test bio".to_string()), + }; + + let preferences = UserPreferences { + theme: Some("dark".to_string()), + language: Some("en".to_string()), + notifications_enabled: true, + auto_sync: false, + }; + + let profile = UserProfile { + id: "test-uuid-1234".to_string(), + hub_did: Some("h:example".to_string()), + identity, + preferences, + created_at: 1234567890, + updated_at: 1234567890, + }; + + assert_eq!(profile.hub_did, Some("h:example".to_string())); + assert_eq!(profile.identity.handle, Some("testuser".to_string())); + assert_eq!(profile.identity.display_name, Some("Test User".to_string())); + assert_eq!(profile.identity.first_name, Some("Test".to_string())); + assert_eq!(profile.identity.last_name, Some("User".to_string())); + assert_eq!(profile.identity.email, Some("test@example.com".to_string())); + assert_eq!(profile.preferences.theme, Some("dark".to_string())); + assert_eq!(profile.preferences.language, Some("en".to_string())); + assert!(profile.preferences.notifications_enabled); + assert!(!profile.preferences.auto_sync); + assert!(!profile.is_default()); + } + + #[test] + fn test_default_user_profile() { + use crate::domain::entities::{UserIdentity, UserPreferences, UserProfile}; + + let profile = UserProfile { + id: "test-uuid-default".to_string(), + hub_did: None, + identity: UserIdentity { + handle: None, + display_name: None, + first_name: None, + last_name: None, + email: None, + avatar_url: None, + bio: None, + }, + preferences: UserPreferences { + theme: None, + language: None, + notifications_enabled: true, + auto_sync: true, + }, + created_at: 1234567890, + updated_at: 1234567890, + }; + + assert!(profile.is_default()); + assert_eq!(profile.hub_did, None); + } + + #[test] + fn test_passport_user_profile_management() { + use crate::domain::entities::{Passport, UserIdentity, UserPreferences, UserProfile, Seed, PublicKey, PrivateKey}; + + let seed = Seed::new(vec![1, 2, 3, 4, 5]); + let public_key = PublicKey(vec![1, 2, 3]); + let private_key = PrivateKey(vec![4, 5, 6]); + let univ_id = "test-universe".to_string(); + + let mut passport = Passport::new(seed, public_key, private_key, univ_id); + + // Test default profile exists + assert!(passport.default_user_profile().is_some()); + assert_eq!(passport.user_profiles().len(), 1); + + // Test adding a hub-specific profile + let hub_profile = UserProfile { + id: "test-uuid-hub".to_string(), + hub_did: Some("h:example".to_string()), + identity: UserIdentity { + handle: Some("hubuser".to_string()), + display_name: Some("Hub User".to_string()), + first_name: Some("Hub".to_string()), + last_name: Some("User".to_string()), + email: None, + avatar_url: None, + bio: None, + }, + preferences: UserPreferences { + theme: Some("light".to_string()), + language: None, + notifications_enabled: false, + auto_sync: true, + }, + created_at: 1234567890, + updated_at: 1234567890, + }; + + let result = passport.add_user_profile(hub_profile); + assert!(result.is_ok()); + assert_eq!(passport.user_profiles().len(), 2); + + // Test finding profile by hub DID + let found_profile = passport.user_profile_for_hub("h:example"); + assert!(found_profile.is_some()); + assert_eq!(found_profile.unwrap().identity.handle, Some("hubuser".to_string())); + assert_eq!(found_profile.unwrap().identity.display_name, Some("Hub User".to_string())); + + // Test duplicate hub DID rejection + let duplicate_profile = UserProfile { + id: "test-uuid-duplicate".to_string(), + hub_did: Some("h:example".to_string()), + identity: UserIdentity { + handle: Some("anotheruser".to_string()), + display_name: Some("Another User".to_string()), + first_name: Some("Another".to_string()), + last_name: Some("User".to_string()), + email: None, + avatar_url: None, + bio: None, + }, + preferences: UserPreferences { + theme: None, + language: None, + notifications_enabled: true, + auto_sync: false, + }, + created_at: 1234567890, + updated_at: 1234567890, + }; + + let result = passport.add_user_profile(duplicate_profile); + assert!(result.is_err()); + + // Test updating profile + let hub_profile_id = passport.user_profile_for_hub("h:example").unwrap().id.clone(); + + let updated_profile = UserProfile { + id: hub_profile_id.clone(), // Same ID as original + hub_did: Some("h:example".to_string()), + identity: UserIdentity { + handle: Some("updateduser".to_string()), + display_name: Some("Updated User".to_string()), + first_name: Some("Updated".to_string()), + last_name: Some("User".to_string()), + email: None, + avatar_url: None, + bio: None, + }, + preferences: UserPreferences { + theme: Some("dark".to_string()), + language: None, + notifications_enabled: true, + auto_sync: false, + }, + created_at: 1234567890, + updated_at: 1234567890, + }; + + let result = passport.update_user_profile_by_id(&hub_profile_id, updated_profile); + assert!(result.is_ok()); + + let found_profile = passport.user_profile_for_hub("h:example"); + assert_eq!(found_profile.unwrap().identity.handle, Some("updateduser".to_string())); + assert_eq!(found_profile.unwrap().identity.display_name, Some("Updated User".to_string())); + + // Test removing profile + let result = passport.remove_user_profile_by_id(&hub_profile_id); + assert!(result.is_ok()); + assert_eq!(passport.user_profiles().len(), 1); + + // Test cannot remove default profile + let default_profile_id = passport.default_user_profile().unwrap().id.clone(); + let result = passport.remove_user_profile_by_id(&default_profile_id); + assert!(result.is_err()); + } } \ No newline at end of file diff --git a/libs/sharenet-passport/src/domain/traits.rs b/libs/sharenet-passport/src/domain/traits.rs index a7b3da3..20d2725 100644 --- a/libs/sharenet-passport/src/domain/traits.rs +++ b/libs/sharenet-passport/src/domain/traits.rs @@ -12,7 +12,7 @@ pub trait KeyDeriver { type Error: Into; fn derive_from_seed(&self, seed: &Seed) -> Result<(PublicKey, PrivateKey), Self::Error>; - fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase) -> Result; + fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase, univ_id: &str) -> Result; } pub trait FileEncryptor { @@ -24,13 +24,15 @@ pub trait FileEncryptor { password: &str, public_key: &PublicKey, did: &Did, + univ_id: &str, + user_profiles: &[UserProfile], ) -> Result; fn decrypt( &self, file: &PassportFile, password: &str, - ) -> Result<(Seed, PublicKey, PrivateKey), Self::Error>; + ) -> Result<(Seed, PublicKey, PrivateKey, Vec), Self::Error>; } pub trait FileStorage { diff --git a/libs/sharenet-passport/src/infrastructure/crypto.rs b/libs/sharenet-passport/src/infrastructure/crypto.rs index 12fb31a..9db3a50 100644 --- a/libs/sharenet-passport/src/infrastructure/crypto.rs +++ b/libs/sharenet-passport/src/infrastructure/crypto.rs @@ -53,13 +53,13 @@ impl KeyDeriver for Ed25519KeyDeriver { )) } - fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase) -> Result { + fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase, univ_id: &str) -> Result { let phrase = mnemonic.words().join(" "); let bip39_mnemonic = Mnemonic::parse(&phrase) .map_err(|e| DomainError::InvalidMnemonic(format!("Invalid mnemonic: {}", e)))?; - // Use empty passphrase for now - let bip39_seed = bip39_mnemonic.to_seed(""); + // Use univ_id as passphrase to bind seed to universe + let bip39_seed = bip39_mnemonic.to_seed(univ_id); Ok(Seed::new(bip39_seed.to_vec())) } } @@ -76,6 +76,8 @@ impl FileEncryptor for XChaCha20FileEncryptor { password: &str, public_key: &PublicKey, did: &Did, + univ_id: &str, + user_profiles: &[UserProfile], ) -> Result { // Generate salt and nonce let mut salt = [0u8; 32]; @@ -96,6 +98,14 @@ impl FileEncryptor for XChaCha20FileEncryptor { .encrypt(nonce, seed.as_bytes()) .map_err(|e| DomainError::CryptographicError(format!("Encryption failed: {}", e)))?; + // Serialize and encrypt user profiles + let user_profiles_vec: Vec = user_profiles.to_vec(); + let user_profiles_bytes = serde_cbor::to_vec(&user_profiles_vec) + .map_err(|e| DomainError::CryptographicError(format!("Failed to serialize user profiles: {}", e)))?; + let enc_user_profiles = cipher + .encrypt(nonce, &*user_profiles_bytes) + .map_err(|e| DomainError::CryptographicError(format!("User profiles encryption failed: {}", e)))?; + // Get current timestamp let created_at = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -110,8 +120,10 @@ impl FileEncryptor for XChaCha20FileEncryptor { nonce: nonce_bytes.to_vec(), public_key: public_key.0.clone(), did: did.0.clone(), + univ_id: univ_id.to_string(), created_at, version: "1.0.0".to_string(), + enc_user_profiles, }) } @@ -119,7 +131,7 @@ impl FileEncryptor for XChaCha20FileEncryptor { &self, file: &PassportFile, password: &str, - ) -> Result<(Seed, PublicKey, PrivateKey), Self::Error> { + ) -> Result<(Seed, PublicKey, PrivateKey, Vec), Self::Error> { // Validate file format if file.kdf != "HKDF-SHA256" || file.cipher != "XChaCha20-Poly1305" { return Err(DomainError::InvalidFileFormat( @@ -153,6 +165,14 @@ impl FileEncryptor for XChaCha20FileEncryptor { )); } - Ok((seed, public_key, private_key)) + // Decrypt user profiles + let user_profiles_bytes = cipher + .decrypt(nonce, &*file.enc_user_profiles) + .map_err(|e| DomainError::CryptographicError(format!("User profiles decryption failed: {}", e)))?; + let user_profiles: Vec = serde_cbor::from_slice(&user_profiles_bytes) + .map_err(|e| DomainError::CryptographicError(format!("Failed to deserialize user profiles: {}", e)))?; + + // Note: univ_id is stored in the PassportFile and will be used when creating the Passport + Ok((seed, public_key, private_key, user_profiles)) } } \ No newline at end of file diff --git a/libs/sharenet-passport/src/infrastructure/crypto_test.rs b/libs/sharenet-passport/src/infrastructure/crypto_test.rs index f2cbcc6..f296efc 100644 --- a/libs/sharenet-passport/src/infrastructure/crypto_test.rs +++ b/libs/sharenet-passport/src/infrastructure/crypto_test.rs @@ -63,7 +63,7 @@ mod tests { let password = "test-password"; // Encrypt - let encrypted_file = encryptor.encrypt(&seed, password, &public_key, &did).unwrap(); + let encrypted_file = encryptor.encrypt(&seed, password, &public_key, &did, "univ:test:crypto", &[]).unwrap(); // Verify file structure assert_eq!(encrypted_file.kdf, "HKDF-SHA256"); @@ -74,7 +74,7 @@ mod tests { assert_eq!(encrypted_file.did, did.0); // Decrypt - let (decrypted_seed, decrypted_public_key, _) = encryptor.decrypt(&encrypted_file, password).unwrap(); + let (decrypted_seed, decrypted_public_key, _, _) = encryptor.decrypt(&encrypted_file, password).unwrap(); // Verify decryption assert_eq!(decrypted_seed.as_bytes(), seed.as_bytes()); @@ -90,7 +90,7 @@ mod tests { let did = Did::new(&public_key); // Encrypt with one password - let encrypted_file = encryptor.encrypt(&seed, "correct-password", &public_key, &did).unwrap(); + let encrypted_file = encryptor.encrypt(&seed, "correct-password", &public_key, &did, "univ:test:crypto", &[]).unwrap(); // Try to decrypt with wrong password let result = encryptor.decrypt(&encrypted_file, "wrong-password"); @@ -111,8 +111,10 @@ mod tests { nonce: vec![0; 24], public_key: vec![1; 32], did: "test-did".to_string(), + univ_id: "univ:test:crypto".to_string(), created_at: 0, version: "1.0.0".to_string(), + enc_user_profiles: vec![], }; // Test with invalid KDF @@ -125,4 +127,129 @@ mod tests { let result = encryptor.decrypt(&invalid_file, "password"); assert!(result.is_err()); } + + #[test] + fn test_file_encryptor_with_user_profiles() { + let encryptor = XChaCha20FileEncryptor; + let key_deriver = Ed25519KeyDeriver; + + let seed = Seed::new(vec![1; 32]); + let (public_key, _) = key_deriver.derive_from_seed(&seed).unwrap(); + let did = Did::new(&public_key); + let password = "test-password"; + + // Create test user profiles + let user_profiles = vec![ + UserProfile { + id: "test-uuid-default".to_string(), + hub_did: None, + identity: UserIdentity { + handle: Some("defaultuser".to_string()), + display_name: Some("Default User".to_string()), + first_name: Some("Default".to_string()), + last_name: Some("User".to_string()), + email: Some("default@example.com".to_string()), + avatar_url: None, + bio: Some("Default bio".to_string()), + }, + preferences: UserPreferences { + theme: Some("dark".to_string()), + language: Some("en".to_string()), + notifications_enabled: true, + auto_sync: true, + }, + created_at: 1234567890, + updated_at: 1234567890, + }, + UserProfile { + id: "test-uuid-hub".to_string(), + hub_did: Some("h:hub1".to_string()), + identity: UserIdentity { + handle: Some("hubuser".to_string()), + display_name: Some("Hub User".to_string()), + first_name: Some("Hub".to_string()), + last_name: Some("User".to_string()), + email: Some("hub@example.com".to_string()), + avatar_url: Some("https://example.com/avatar.png".to_string()), + bio: Some("Hub bio".to_string()), + }, + preferences: UserPreferences { + theme: Some("light".to_string()), + language: Some("fr".to_string()), + notifications_enabled: false, + auto_sync: false, + }, + created_at: 1234567891, + updated_at: 1234567892, + }, + ]; + + // Encrypt + let encrypted_file = encryptor.encrypt( + &seed, + password, + &public_key, + &did, + "univ:test:crypto", + &user_profiles + ).unwrap(); + + // Verify file structure includes user profiles + assert_eq!(encrypted_file.kdf, "HKDF-SHA256"); + assert_eq!(encrypted_file.cipher, "XChaCha20-Poly1305"); + assert!(!encrypted_file.enc_user_profiles.is_empty()); + + // Decrypt + let (decrypted_seed, decrypted_public_key, _, decrypted_profiles) = + encryptor.decrypt(&encrypted_file, password).unwrap(); + + // Verify decryption + assert_eq!(decrypted_seed.as_bytes(), seed.as_bytes()); + assert_eq!(decrypted_public_key.0, public_key.0); + + // Verify user profiles + assert_eq!(decrypted_profiles.len(), 2); + + // Verify default profile + let default_profile = decrypted_profiles.iter().find(|p| p.is_default()).unwrap(); + assert_eq!(default_profile.identity.handle, Some("defaultuser".to_string())); + assert_eq!(default_profile.identity.display_name, Some("Default User".to_string())); + assert_eq!(default_profile.identity.email, Some("default@example.com".to_string())); + assert_eq!(default_profile.preferences.theme, Some("dark".to_string())); + + // Verify hub profile + let hub_profile = decrypted_profiles.iter().find(|p| p.hub_did == Some("h:hub1".to_string())).unwrap(); + assert_eq!(hub_profile.identity.handle, Some("hubuser".to_string())); + assert_eq!(hub_profile.identity.display_name, Some("Hub User".to_string())); + assert_eq!(hub_profile.identity.email, Some("hub@example.com".to_string())); + assert_eq!(hub_profile.preferences.language, Some("fr".to_string())); + assert!(!hub_profile.preferences.notifications_enabled); + } + + #[test] + fn test_file_encryptor_with_empty_user_profiles() { + let encryptor = XChaCha20FileEncryptor; + let key_deriver = Ed25519KeyDeriver; + + let seed = Seed::new(vec![1; 32]); + let (public_key, _) = key_deriver.derive_from_seed(&seed).unwrap(); + let did = Did::new(&public_key); + let password = "test-password"; + + // Encrypt with empty user profiles + let encrypted_file = encryptor.encrypt( + &seed, + password, + &public_key, + &did, + "univ:test:crypto", + &[] + ).unwrap(); + + // Decrypt + let (_, _, _, decrypted_profiles) = encryptor.decrypt(&encrypted_file, password).unwrap(); + + // Should have empty profiles + assert_eq!(decrypted_profiles.len(), 0); + } } \ No newline at end of file diff --git a/libs/sharenet-passport/src/infrastructure/storage_test.rs b/libs/sharenet-passport/src/infrastructure/storage_test.rs index 9ce4cdd..916be53 100644 --- a/libs/sharenet-passport/src/infrastructure/storage_test.rs +++ b/libs/sharenet-passport/src/infrastructure/storage_test.rs @@ -18,8 +18,10 @@ mod tests { nonce: vec![0; 24], public_key: vec![1; 32], did: "test-did".to_string(), + univ_id: "univ:test:storage".to_string(), created_at: 1234567890, version: "1.0.0".to_string(), + enc_user_profiles: vec![], }; // Save file diff --git a/libs/sharenet-passport/src/universe_binding_test.rs b/libs/sharenet-passport/src/universe_binding_test.rs new file mode 100644 index 0000000..33bfd91 --- /dev/null +++ b/libs/sharenet-passport/src/universe_binding_test.rs @@ -0,0 +1,232 @@ +use crate::application::use_cases::*; +use crate::domain::entities::*; +use crate::infrastructure::crypto::*; +use crate::infrastructure::storage::*; +use std::fs; + +#[cfg(test)] +mod universe_binding_tests { + use super::*; + + #[test] + fn test_passport_creation_with_different_universes() { + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator.clone(), + key_deriver.clone(), + file_encryptor.clone(), + file_storage.clone(), + ); + + // Create passports for different universes with the same mnemonic + let univ1 = "univ:test:alpha"; + let univ2 = "univ:test:beta"; + let password = "test_password"; + + // Create first passport + let (passport1, recovery_phrase) = create_use_case + .execute(univ1, password, "/tmp/test_passport1.spf") + .expect("Failed to create passport 1"); + + // Create second passport with same mnemonic but different universe + let import_use_case = ImportFromRecoveryUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor, + file_storage, + ); + + let passport2 = import_use_case + .execute( + univ2, + &recovery_phrase.words(), + password, + "/tmp/test_passport2.spf", + ) + .expect("Failed to create passport 2"); + + // Verify universe binding + assert_eq!(passport1.univ_id(), univ1); + assert_eq!(passport2.univ_id(), univ2); + + // Verify DIDs are universe-bound + assert!(passport1.did().as_str().contains(univ1)); + assert!(passport2.did().as_str().contains(univ2)); + assert_ne!(passport1.did().as_str(), passport2.did().as_str()); + + // Verify public keys are different (due to universe binding) + assert_ne!( + hex::encode(&passport1.public_key().0), + hex::encode(&passport2.public_key().0) + ); + + // Clean up + let _ = fs::remove_file("/tmp/test_passport1.spf"); + let _ = fs::remove_file("/tmp/test_passport2.spf"); + } + + #[test] + fn test_universe_bound_card_signing() { + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor, + file_storage, + ); + + let univ_id = "univ:test:signing"; + let password = "test_password"; + + let (passport, _) = create_use_case + .execute(univ_id, password, "/tmp/test_signing.spf") + .expect("Failed to create passport"); + + let sign_use_case = SignCardUseCase::new(); + let message = "Hello, universe!"; + let signature = sign_use_case + .execute(&passport, message) + .expect("Failed to sign message"); + + // Verify signature is universe-bound + let signing_key = ed25519_dalek::SigningKey::from_bytes( + &passport.private_key.0[..32].try_into().unwrap() + ); + let verifying_key = signing_key.verifying_key(); + + // Correct universe-bound message should verify + let correct_message = format!("univ:{}:{}", univ_id, message); + assert!(verifying_key + .verify_strict(correct_message.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature).unwrap()) + .is_ok()); + + // Wrong universe message should NOT verify + let wrong_message = format!("univ:{}:{}", "univ:wrong:universe", message); + assert!(verifying_key + .verify_strict(wrong_message.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature).unwrap()) + .is_err()); + + // Clean up + let _ = fs::remove_file("/tmp/test_signing.spf"); + } + + #[test] + fn test_passport_file_stores_univ_id() { + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor.clone(), + file_storage.clone(), + ); + + let univ_id = "univ:test:storage"; + let password = "test_password"; + + let (passport, _) = create_use_case + .execute(univ_id, password, "/tmp/test_storage.spf") + .expect("Failed to create passport"); + + // Load the file and verify univ_id is stored + let loaded_file = file_storage + .load("/tmp/test_storage.spf") + .expect("Failed to load passport file"); + + assert_eq!(loaded_file.univ_id, univ_id); + + // Import from file and verify univ_id is preserved + let import_use_case = ImportFromFileUseCase::new(file_encryptor, file_storage); + let imported_passport = import_use_case + .execute("/tmp/test_storage.spf", password, None) + .expect("Failed to import passport"); + + assert_eq!(imported_passport.univ_id(), univ_id); + assert_eq!(imported_passport.did().as_str(), passport.did().as_str()); + + // Clean up + let _ = fs::remove_file("/tmp/test_storage.spf"); + } + + #[test] + fn test_cross_universe_prevention() { + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator.clone(), + key_deriver.clone(), + file_encryptor.clone(), + file_storage.clone(), + ); + + let univ1 = "univ:test:security1"; + let univ2 = "univ:test:security2"; + let password = "test_password"; + + // Create passport for universe 1 + let (passport1, recovery_phrase) = create_use_case + .execute(univ1, password, "/tmp/test_security1.spf") + .expect("Failed to create passport 1"); + + // Try to import same mnemonic into universe 2 + let import_use_case = ImportFromRecoveryUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor, + file_storage, + ); + + let passport2 = import_use_case + .execute( + univ2, + &recovery_phrase.words(), + password, + "/tmp/test_security2.spf", + ) + .expect("Failed to create passport 2"); + + // Verify they are completely different identities + assert_ne!(passport1.univ_id(), passport2.univ_id()); + assert_ne!(passport1.did().as_str(), passport2.did().as_str()); + assert_ne!( + hex::encode(&passport1.public_key().0), + hex::encode(&passport2.public_key().0) + ); + + // Cards signed by passport1 should not be verifiable by passport2 and vice versa + let sign_use_case = SignCardUseCase::new(); + let message = "Cross-universe test"; + let signature1 = sign_use_case + .execute(&passport1, message) + .expect("Failed to sign with passport1"); + + // Verify signature1 cannot be verified with passport2's public key + let signing_key2 = ed25519_dalek::SigningKey::from_bytes( + &passport2.private_key.0[..32].try_into().unwrap() + ); + let verifying_key2 = signing_key2.verifying_key(); + + let message_for_univ1 = format!("univ:{}:{}", univ1, message); + assert!(verifying_key2 + .verify_strict(message_for_univ1.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature1).unwrap()) + .is_err()); + + // Clean up + let _ = fs::remove_file("/tmp/test_security1.spf"); + let _ = fs::remove_file("/tmp/test_security2.spf"); + } +} \ No newline at end of file diff --git a/sharenet-passport-cli/Cargo.toml b/sharenet-passport-cli/Cargo.toml index 82fe5e5..8d18390 100644 --- a/sharenet-passport-cli/Cargo.toml +++ b/sharenet-passport-cli/Cargo.toml @@ -10,6 +10,7 @@ sharenet-passport = { path = "../libs/sharenet-passport" } clap = { version = "4.4", features = ["derive"] } rpassword = "7.2" hex = "0.4" +uuid = { version = "1.7", features = ["v7"] } [dev-dependencies] assert_matches = "1.5" diff --git a/sharenet-passport-cli/src/bin/test_universe_binding.rs b/sharenet-passport-cli/src/bin/test_universe_binding.rs new file mode 100644 index 0000000..352dfcc --- /dev/null +++ b/sharenet-passport-cli/src/bin/test_universe_binding.rs @@ -0,0 +1,105 @@ +use sharenet_passport::application::use_cases::*; +use sharenet_passport::domain::traits::*; +use sharenet_passport::infrastructure::crypto::*; +use sharenet_passport::infrastructure::storage::*; +use std::fs; + +fn main() { + println!("Testing universe binding implementation..."); + + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator.clone(), + key_deriver.clone(), + file_encryptor.clone(), + file_storage.clone(), + ); + + // Test 1: Create passports for different universes + println!("\nTest 1: Creating passports for different universes..."); + let univ1 = "univ:test:alpha"; + let univ2 = "univ:test:beta"; + let password = "test_password"; + + let (passport1, recovery_phrase) = create_use_case + .execute(univ1, password, "/tmp/test_passport1.spf") + .expect("Failed to create passport 1"); + + println!("✓ Passport 1 created for universe: {}", passport1.univ_id()); + println!(" DID: {}", passport1.did().as_str()); + + let import_use_case = ImportFromRecoveryUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor, + file_storage, + ); + + let passport2 = import_use_case + .execute( + univ2, + &recovery_phrase.words(), + password, + "/tmp/test_passport2.spf", + ) + .expect("Failed to create passport 2"); + + println!("✓ Passport 2 created for universe: {}", passport2.univ_id()); + println!(" DID: {}", passport2.did().as_str()); + + // Verify universe binding + assert_eq!(passport1.univ_id(), univ1); + assert_eq!(passport2.univ_id(), univ2); + assert_ne!(passport1.did().as_str(), passport2.did().as_str()); + assert_ne!( + hex::encode(&passport1.public_key().0), + hex::encode(&passport2.public_key().0) + ); + + println!("✓ Universe binding verified - different universes produce different identities"); + + // Test 2: Universe-bound card signing + println!("\nTest 2: Testing universe-bound card signing..."); + let sign_use_case = SignCardUseCase::new(); + let message = "Hello, universe!"; + let _signature = sign_use_case + .execute(&passport1, message) + .expect("Failed to sign message"); + + println!("✓ Message signed successfully"); + + // Test 3: Verify PassportFile stores univ_id + println!("\nTest 3: Verifying PassportFile stores univ_id..."); + let loaded_file = FileSystemStorage + .load("/tmp/test_passport1.spf") + .expect("Failed to load passport file"); + + assert_eq!(loaded_file.univ_id, univ1); + println!("✓ PassportFile correctly stores univ_id: {}", loaded_file.univ_id); + + // Test 4: Import from file preserves univ_id + println!("\nTest 4: Testing import from file preserves univ_id..."); + let import_file_use_case = ImportFromFileUseCase::new(XChaCha20FileEncryptor, FileSystemStorage); + let imported_passport = import_file_use_case + .execute("/tmp/test_passport1.spf", password, None) + .expect("Failed to import passport"); + + assert_eq!(imported_passport.univ_id(), univ1); + println!("✓ Imported passport preserves univ_id: {}", imported_passport.univ_id()); + + // Clean up + let _ = fs::remove_file("/tmp/test_passport1.spf"); + let _ = fs::remove_file("/tmp/test_passport2.spf"); + + println!("\n🎉 All universe binding tests passed successfully!"); + println!("\nSummary:"); + println!("- Passports are cryptographically bound to their universe"); + println!("- Same mnemonic + different universe = different identities"); + println!("- DIDs include universe identifier"); + println!("- Card signatures are universe-bound"); + println!("- Passport files store univ_id for verification"); +} \ No newline at end of file diff --git a/sharenet-passport-cli/src/cli/commands.rs b/sharenet-passport-cli/src/cli/commands.rs index 9c0a622..111cb3c 100644 --- a/sharenet-passport-cli/src/cli/commands.rs +++ b/sharenet-passport-cli/src/cli/commands.rs @@ -1,7 +1,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "sharenet-passport")] +#[command(name = "sharenet-passport-cli")] #[command(about = "Sharenet Passport Creator - Generate and manage cryptographic identities")] #[command(version)] pub struct Cli { @@ -13,13 +13,27 @@ pub struct Cli { pub enum Commands { /// Create a new Passport Create { + /// Universe identifier (e.g., "u:My Universe:uuid") + #[arg(short, long)] + universe: String, + /// Output file path for the .spf file #[arg(short, long, default_value = "passport.spf")] output: String, }, + /// Create a new universe identifier + CreateUniverse { + /// Universe name + name: String, + }, + /// Import a Passport from recovery phrase ImportRecovery { + /// Universe identifier (e.g., "u:My Universe:uuid") + #[arg(short, long)] + universe: String, + /// Output file path for the .spf file #[arg(short, long, default_value = "passport.spf")] output: String, @@ -59,4 +73,141 @@ pub enum Commands { /// Message to sign message: String, }, + + /// User Profile Management + Profile { + #[command(subcommand)] + command: ProfileCommands, + }, +} + +#[derive(Subcommand)] +pub enum ProfileCommands { + /// List all user profiles + List { + /// .spf file path + file: String, + }, + + /// Create a new user profile + Create { + /// .spf file path + file: String, + + /// Hub DID (optional, omit for default profile) + #[arg(short, long)] + hub_did: Option, + + /// Handle + #[arg(long)] + handle: Option, + + /// Display name + #[arg(short, long)] + display_name: Option, + + /// First name + #[arg(long)] + first_name: Option, + + /// Last name + #[arg(long)] + last_name: Option, + + /// Email + #[arg(short, long)] + email: Option, + + /// Avatar URL + #[arg(short, long)] + avatar_url: Option, + + /// Bio + #[arg(short, long)] + bio: Option, + + /// Theme preference + #[arg(long)] + theme: Option, + + /// Language preference + #[arg(long)] + language: Option, + + /// Enable notifications + #[arg(long)] + notifications: bool, + + /// Enable auto-sync + #[arg(long)] + auto_sync: bool, + }, + + /// Update an existing user profile + Update { + /// .spf file path + file: String, + + /// Profile ID (required, use 'list' command to see available IDs) + #[arg(short, long)] + id: String, + + /// Hub DID (optional, can be updated) + #[arg(long)] + hub_did: Option, + + /// Handle + #[arg(long)] + handle: Option, + + /// Display name + #[arg(short, long)] + display_name: Option, + + /// First name + #[arg(long)] + first_name: Option, + + /// Last name + #[arg(long)] + last_name: Option, + + /// Email + #[arg(short, long)] + email: Option, + + /// Avatar URL + #[arg(short, long)] + avatar_url: Option, + + /// Bio + #[arg(short, long)] + bio: Option, + + /// Theme preference + #[arg(long)] + theme: Option, + + /// Language preference + #[arg(long)] + language: Option, + + /// Enable notifications + #[arg(long)] + notifications: Option, + + /// Enable auto-sync + #[arg(long)] + auto_sync: Option, + }, + + /// Delete a user profile + Delete { + /// .spf file path + file: String, + + /// Profile ID (required, use 'list' command to see available IDs) + #[arg(short, long)] + id: String, + }, } \ No newline at end of file diff --git a/sharenet-passport-cli/src/cli/interface.rs b/sharenet-passport-cli/src/cli/interface.rs index 1dc6c08..2722dc1 100644 --- a/sharenet-passport-cli/src/cli/interface.rs +++ b/sharenet-passport-cli/src/cli/interface.rs @@ -1,11 +1,13 @@ use sharenet_passport::{ application::use_cases::*, + domain::entities::{UserIdentity, UserPreferences}, infrastructure::*, ApplicationError, FileStorage, }; use rpassword::prompt_password; use hex; +use uuid::Uuid; pub struct CliInterface; @@ -14,9 +16,24 @@ impl CliInterface { Self } - pub fn handle_create(&self, output: &str) -> Result<(), ApplicationError> { - let password = prompt_password("Enter password for new passport: ").unwrap(); - let confirm_password = prompt_password("Confirm password: ").unwrap(); + pub fn handle_create_universe(&self, name: &str) -> Result<(), ApplicationError> { + let uuid = Uuid::now_v7(); + let universe_id = format!("u:{}:{}", name, uuid); + + println!("🌌 Universe created successfully!"); + println!("📝 Universe Name: {}", name); + println!("🆔 Universe ID: {}", universe_id); + println!("\n💡 Use this Universe ID when creating Passports:"); + println!(" sharenet-passport create --universe '{}'", universe_id); + + Ok(()) + } + + pub fn handle_create(&self, universe: &str, output: &str) -> Result<(), ApplicationError> { + let password = prompt_password("Enter password for new passport: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + let confirm_password = prompt_password("Confirm password: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; if password != confirm_password { return Err(ApplicationError::UseCaseError("Passwords do not match".to_string())); @@ -29,7 +46,7 @@ impl CliInterface { FileSystemStorage, ); - let (passport, recovery_phrase) = use_case.execute(&password, output)?; + let (passport, recovery_phrase) = use_case.execute(universe, &password, output)?; println!("✅ Passport created successfully!"); println!("📄 Saved to: {}", output); @@ -41,17 +58,20 @@ impl CliInterface { Ok(()) } - pub fn handle_import_recovery(&self, output: &str) -> Result<(), ApplicationError> { + pub fn handle_import_recovery(&self, universe: &str, output: &str) -> Result<(), ApplicationError> { println!("Enter your 24-word recovery phrase:"); let mut recovery_words = Vec::new(); for i in 1..=24 { - let word = prompt_password(&format!("Word {}: ", i)).unwrap(); + let word = prompt_password(&format!("Word {}: ", i)) + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read recovery word: {}", e)))?; recovery_words.push(word); } - let password = prompt_password("Enter new password for passport file: ").unwrap(); - let confirm_password = prompt_password("Confirm password: ").unwrap(); + let password = prompt_password("Enter new password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + let confirm_password = prompt_password("Confirm password: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; if password != confirm_password { return Err(ApplicationError::UseCaseError("Passwords do not match".to_string())); @@ -64,7 +84,7 @@ impl CliInterface { FileSystemStorage, ); - let passport = use_case.execute(&recovery_words, &password, output)?; + let passport = use_case.execute(universe, &recovery_words, &password, output)?; println!("✅ Passport imported successfully!"); println!("📄 Saved to: {}", output); @@ -75,7 +95,8 @@ impl CliInterface { } pub fn handle_import_file(&self, input: &str, output: Option<&str>) -> Result<(), ApplicationError> { - let password = prompt_password("Enter password for passport file: ").unwrap(); + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; let use_case = ImportFromFileUseCase::new( XChaCha20FileEncryptor, @@ -95,9 +116,12 @@ impl CliInterface { } pub fn handle_export(&self, input: &str, output: &str) -> Result<(), ApplicationError> { - let password = prompt_password("Enter password for passport file: ").unwrap(); - let new_password = prompt_password("Enter new password for exported file: ").unwrap(); - let confirm_password = prompt_password("Confirm new password: ").unwrap(); + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + let new_password = prompt_password("Enter new password for exported file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + let confirm_password = prompt_password("Confirm new password: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; if new_password != confirm_password { return Err(ApplicationError::UseCaseError("Passwords do not match".to_string())); @@ -131,6 +155,7 @@ impl CliInterface { println!("📄 Passport File Information:"); println!(" File: {}", file); + println!(" Universe ID: {}", passport_file.univ_id); println!(" Version: {}", passport_file.version); println!(" Created: {}", passport_file.created_at); println!(" DID: {}", passport_file.did); @@ -142,7 +167,8 @@ impl CliInterface { } pub fn handle_sign(&self, file: &str, message: &str) -> Result<(), ApplicationError> { - let password = prompt_password("Enter password for passport file: ").unwrap(); + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; let import_use_case = ImportFromFileUseCase::new( XChaCha20FileEncryptor, @@ -160,4 +186,252 @@ impl CliInterface { Ok(()) } + + pub fn handle_profile_list(&self, file: &str) -> Result<(), ApplicationError> { + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + + let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let passport = import_use_case.execute(file, &password, None)?; + + println!("👤 User Profiles:"); + for (i, profile) in passport.user_profiles().iter().enumerate() { + println!("\n{}. Profile ID: {}", i + 1, profile.id); + println!(" Profile Type: {}", if profile.is_default() { "Default" } else { "Hub-specific" }); + if let Some(hub_did) = &profile.hub_did { + println!(" Hub DID: {}", hub_did); + } + println!(" Created: {}", profile.created_at); + println!(" Updated: {}", profile.updated_at); + + println!(" Identity:"); + if let Some(handle) = &profile.identity.handle { + println!(" Handle: {}", handle); + } + if let Some(name) = &profile.identity.display_name { + println!(" Display Name: {}", name); + } + if let Some(first_name) = &profile.identity.first_name { + println!(" First Name: {}", first_name); + } + if let Some(last_name) = &profile.identity.last_name { + println!(" Last Name: {}", last_name); + } + if let Some(email) = &profile.identity.email { + println!(" Email: {}", email); + } + if let Some(avatar) = &profile.identity.avatar_url { + println!(" Avatar URL: {}", avatar); + } + if let Some(bio) = &profile.identity.bio { + println!(" Bio: {}", bio); + } + + println!(" Preferences:"); + if let Some(theme) = &profile.preferences.theme { + println!(" Theme: {}", theme); + } + if let Some(language) = &profile.preferences.language { + println!(" Language: {}", language); + } + println!(" Notifications: {}", if profile.preferences.notifications_enabled { "Enabled" } else { "Disabled" }); + println!(" Auto-sync: {}", if profile.preferences.auto_sync { "Enabled" } else { "Disabled" }); + } + + Ok(()) + } + + pub fn handle_profile_create( + &self, + file: &str, + hub_did: Option, + handle: Option, + display_name: Option, + first_name: Option, + last_name: Option, + email: Option, + avatar_url: Option, + bio: Option, + theme: Option, + language: Option, + notifications: bool, + auto_sync: bool, + ) -> Result<(), ApplicationError> { + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + + let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let mut passport = import_use_case.execute(file, &password, None)?; + + let identity = UserIdentity { + handle, + display_name, + first_name, + last_name, + email, + avatar_url, + bio, + }; + + let preferences = UserPreferences { + theme, + language, + notifications_enabled: notifications, + auto_sync, + }; + + let create_use_case = CreateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + create_use_case.execute( + &mut passport, + hub_did.clone(), + identity, + preferences, + &password, + file, + )?; + + println!("✅ User profile created successfully!"); + if let Some(hub_did) = hub_did { + println!("📡 Hub DID: {}", hub_did); + } else { + println!("🏠 Profile Type: Default"); + } + + Ok(()) + } + + pub fn handle_profile_update( + &self, + file: &str, + id: &str, + hub_did: Option, + handle: Option, + display_name: Option, + first_name: Option, + last_name: Option, + email: Option, + avatar_url: Option, + bio: Option, + theme: Option, + language: Option, + notifications: Option, + auto_sync: Option, + ) -> Result<(), ApplicationError> { + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + + let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let mut passport = import_use_case.execute(file, &password, None)?; + + // Get existing profile by ID + let existing_profile = passport.user_profile_by_id(id) + .ok_or_else(|| ApplicationError::UseCaseError("User profile not found".to_string()))?; + + let identity = UserIdentity { + handle: handle.or_else(|| existing_profile.identity.handle.clone()), + display_name: display_name.or_else(|| existing_profile.identity.display_name.clone()), + first_name: first_name.or_else(|| existing_profile.identity.first_name.clone()), + last_name: last_name.or_else(|| existing_profile.identity.last_name.clone()), + email: email.or_else(|| existing_profile.identity.email.clone()), + avatar_url: avatar_url.or_else(|| existing_profile.identity.avatar_url.clone()), + bio: bio.or_else(|| existing_profile.identity.bio.clone()), + }; + + let preferences = UserPreferences { + theme: theme.or_else(|| existing_profile.preferences.theme.clone()), + language: language.or_else(|| existing_profile.preferences.language.clone()), + notifications_enabled: notifications.unwrap_or(existing_profile.preferences.notifications_enabled), + auto_sync: auto_sync.unwrap_or(existing_profile.preferences.auto_sync), + }; + + // Clone values before using them in multiple places + let identity_clone = identity.clone(); + let preferences_clone = preferences.clone(); + let hub_did_clone = hub_did.clone(); + + // Create updated profile with new hub_did if provided + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| ApplicationError::UseCaseError(format!("Time error: {}", e)))? + .as_secs(); + + let _profile = sharenet_passport::domain::entities::UserProfile { + id: existing_profile.id.clone(), + hub_did: hub_did.or_else(|| existing_profile.hub_did.clone()), + identity, + preferences, + created_at: existing_profile.created_at, + updated_at: now, + }; + + // Use the update use case to handle the profile update and file saving + let update_use_case = UpdateUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + update_use_case.execute( + &mut passport, + Some(id), + identity_clone, + preferences_clone, + &password, + file, + )?; + + println!("✅ User profile updated successfully!"); + if let Some(hub_did) = hub_did_clone { + println!("📡 Hub DID: {}", hub_did); + } else { + println!("🏠 Profile Type: Default"); + } + + Ok(()) + } + + pub fn handle_profile_delete(&self, file: &str, id: &str) -> Result<(), ApplicationError> { + let password = prompt_password("Enter password for passport file: ") + .map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?; + + let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + let mut passport = import_use_case.execute(file, &password, None)?; + + // Use the delete use case to handle the profile removal and file saving + let delete_use_case = DeleteUserProfileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, + ); + + delete_use_case.execute( + &mut passport, + Some(id), + &password, + file, + )?; + + println!("✅ User profile deleted successfully!"); + println!("🆔 Profile ID: {}", id); + + Ok(()) + } } \ No newline at end of file diff --git a/sharenet-passport-cli/src/main.rs b/sharenet-passport-cli/src/main.rs index a58b191..06ff162 100644 --- a/sharenet-passport-cli/src/main.rs +++ b/sharenet-passport-cli/src/main.rs @@ -10,11 +10,14 @@ fn main() -> Result<(), Box> { let interface = CliInterface::new(); match cli.command { - Commands::Create { output } => { - interface.handle_create(&output)?; + Commands::Create { universe, output } => { + interface.handle_create(&universe, &output)?; } - Commands::ImportRecovery { output } => { - interface.handle_import_recovery(&output)?; + Commands::CreateUniverse { name } => { + interface.handle_create_universe(&name)?; + } + Commands::ImportRecovery { universe, output } => { + interface.handle_import_recovery(&universe, &output)?; } Commands::ImportFile { input, output } => { interface.handle_import_file(&input, output.as_deref())?; @@ -28,6 +31,80 @@ fn main() -> Result<(), Box> { Commands::Sign { file, message } => { interface.handle_sign(&file, &message)?; } + Commands::Profile { command } => { + match command { + crate::cli::commands::ProfileCommands::List { file } => { + interface.handle_profile_list(&file)?; + } + crate::cli::commands::ProfileCommands::Create { + file, + hub_did, + handle, + display_name, + first_name, + last_name, + email, + avatar_url, + bio, + theme, + language, + notifications, + auto_sync, + } => { + interface.handle_profile_create( + &file, + hub_did, + handle, + display_name, + first_name, + last_name, + email, + avatar_url, + bio, + theme, + language, + notifications, + auto_sync, + )?; + } + crate::cli::commands::ProfileCommands::Update { + file, + id, + hub_did, + handle, + display_name, + first_name, + last_name, + email, + avatar_url, + bio, + theme, + language, + notifications, + auto_sync, + } => { + interface.handle_profile_update( + &file, + &id, + hub_did, + handle, + display_name, + first_name, + last_name, + email, + avatar_url, + bio, + theme, + language, + notifications, + auto_sync, + )?; + } + crate::cli::commands::ProfileCommands::Delete { file, id } => { + interface.handle_profile_delete(&file, &id)?; + } + } + } } Ok(()) diff --git a/src/bin/test_universe_binding.rs b/src/bin/test_universe_binding.rs new file mode 100644 index 0000000..13b0455 --- /dev/null +++ b/src/bin/test_universe_binding.rs @@ -0,0 +1,105 @@ +use sharenet_passport::application::use_cases::*; +use sharenet_passport::domain::entities::*; +use sharenet_passport::infrastructure::crypto::*; +use sharenet_passport::infrastructure::storage::*; +use std::fs; + +fn main() { + println!("Testing universe binding implementation..."); + + let mnemonic_generator = Bip39MnemonicGenerator; + let key_deriver = Ed25519KeyDeriver; + let file_encryptor = XChaCha20FileEncryptor; + let file_storage = FileSystemStorage; + + let create_use_case = CreatePassportUseCase::new( + mnemonic_generator.clone(), + key_deriver.clone(), + file_encryptor.clone(), + file_storage.clone(), + ); + + // Test 1: Create passports for different universes + println!("\nTest 1: Creating passports for different universes..."); + let univ1 = "univ:test:alpha"; + let univ2 = "univ:test:beta"; + let password = "test_password"; + + let (passport1, recovery_phrase) = create_use_case + .execute(univ1, password, "/tmp/test_passport1.spf") + .expect("Failed to create passport 1"); + + println!("✓ Passport 1 created for universe: {}", passport1.univ_id()); + println!(" DID: {}", passport1.did().as_str()); + + let import_use_case = ImportFromRecoveryUseCase::new( + mnemonic_generator, + key_deriver, + file_encryptor, + file_storage, + ); + + let passport2 = import_use_case + .execute( + univ2, + &recovery_phrase.words(), + password, + "/tmp/test_passport2.spf", + ) + .expect("Failed to create passport 2"); + + println!("✓ Passport 2 created for universe: {}", passport2.univ_id()); + println!(" DID: {}", passport2.did().as_str()); + + // Verify universe binding + assert_eq!(passport1.univ_id(), univ1); + assert_eq!(passport2.univ_id(), univ2); + assert_ne!(passport1.did().as_str(), passport2.did().as_str()); + assert_ne!( + hex::encode(&passport1.public_key().0), + hex::encode(&passport2.public_key().0) + ); + + println!("✓ Universe binding verified - different universes produce different identities"); + + // Test 2: Universe-bound card signing + println!("\nTest 2: Testing universe-bound card signing..."); + let sign_use_case = SignCardUseCase::new(); + let message = "Hello, universe!"; + let signature = sign_use_case + .execute(&passport1, message) + .expect("Failed to sign message"); + + println!("✓ Message signed successfully"); + + // Test 3: Verify PassportFile stores univ_id + println!("\nTest 3: Verifying PassportFile stores univ_id..."); + let loaded_file = FileSystemStorage + .load("/tmp/test_passport1.spf") + .expect("Failed to load passport file"); + + assert_eq!(loaded_file.univ_id, univ1); + println!("✓ PassportFile correctly stores univ_id: {}", loaded_file.univ_id); + + // Test 4: Import from file preserves univ_id + println!("\nTest 4: Testing import from file preserves univ_id..."); + let import_file_use_case = ImportFromFileUseCase::new(XChaCha20FileEncryptor, FileSystemStorage); + let imported_passport = import_file_use_case + .execute("/tmp/test_passport1.spf", password, None) + .expect("Failed to import passport"); + + assert_eq!(imported_passport.univ_id(), univ1); + println!("✓ Imported passport preserves univ_id: {}", imported_passport.univ_id()); + + // Clean up + let _ = fs::remove_file("/tmp/test_passport1.spf"); + let _ = fs::remove_file("/tmp/test_passport2.spf"); + + println!("\n🎉 All universe binding tests passed successfully!"); + println!("\nSummary:"); + println!("- Passports are cryptographically bound to their universe"); + println!("- Same mnemonic + different universe = different identities"); + println!("- DIDs include universe identifier"); + println!("- Card signatures are universe-bound"); + println!("- Passport files store univ_id for verification"); +} \ No newline at end of file