Add universe binding and user profiles to passport

This commit is contained in:
Continuist 2025-10-17 21:38:08 -04:00
parent 5eea31a25d
commit af0d66d370
17 changed files with 2494 additions and 54 deletions

147
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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<MG, KD, FE, FS>
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<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> CreateUserProfileUseCase<FE, FS>
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<String>,
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<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> UpdateUserProfileUseCase<FE, FS>
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<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> DeleteUserProfileUseCase<FE, FS>
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(())
}
}

View file

@ -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());
}
}

View file

@ -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<UserProfile>,
}
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<String>,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPreferences {
pub theme: Option<String>,
pub language: Option<String>,
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<String>, // 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<String>,
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<u8>,
pub public_key: Vec<u8>,
pub did: String,
pub univ_id: String,
pub created_at: u64,
pub version: String,
pub enc_user_profiles: Vec<u8>, // Encrypted CBOR of Vec<UserProfile>
}

View file

@ -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());
}
}

View file

@ -12,7 +12,7 @@ pub trait KeyDeriver {
type Error: Into<DomainError>;
fn derive_from_seed(&self, seed: &Seed) -> Result<(PublicKey, PrivateKey), Self::Error>;
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase) -> Result<Seed, Self::Error>;
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase, univ_id: &str) -> Result<Seed, Self::Error>;
}
pub trait FileEncryptor {
@ -24,13 +24,15 @@ pub trait FileEncryptor {
password: &str,
public_key: &PublicKey,
did: &Did,
univ_id: &str,
user_profiles: &[UserProfile],
) -> Result<PassportFile, Self::Error>;
fn decrypt(
&self,
file: &PassportFile,
password: &str,
) -> Result<(Seed, PublicKey, PrivateKey), Self::Error>;
) -> Result<(Seed, PublicKey, PrivateKey, Vec<UserProfile>), Self::Error>;
}
pub trait FileStorage {

View file

@ -53,13 +53,13 @@ impl KeyDeriver for Ed25519KeyDeriver {
))
}
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase) -> Result<Seed, Self::Error> {
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase, univ_id: &str) -> Result<Seed, Self::Error> {
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<PassportFile, Self::Error> {
// 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<UserProfile> = 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<UserProfile>), 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<UserProfile> = 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))
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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");
}
}

View file

@ -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"

View file

@ -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");
}

View file

@ -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<String>,
/// Handle
#[arg(long)]
handle: Option<String>,
/// Display name
#[arg(short, long)]
display_name: Option<String>,
/// First name
#[arg(long)]
first_name: Option<String>,
/// Last name
#[arg(long)]
last_name: Option<String>,
/// Email
#[arg(short, long)]
email: Option<String>,
/// Avatar URL
#[arg(short, long)]
avatar_url: Option<String>,
/// Bio
#[arg(short, long)]
bio: Option<String>,
/// Theme preference
#[arg(long)]
theme: Option<String>,
/// Language preference
#[arg(long)]
language: Option<String>,
/// 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<String>,
/// Handle
#[arg(long)]
handle: Option<String>,
/// Display name
#[arg(short, long)]
display_name: Option<String>,
/// First name
#[arg(long)]
first_name: Option<String>,
/// Last name
#[arg(long)]
last_name: Option<String>,
/// Email
#[arg(short, long)]
email: Option<String>,
/// Avatar URL
#[arg(short, long)]
avatar_url: Option<String>,
/// Bio
#[arg(short, long)]
bio: Option<String>,
/// Theme preference
#[arg(long)]
theme: Option<String>,
/// Language preference
#[arg(long)]
language: Option<String>,
/// Enable notifications
#[arg(long)]
notifications: Option<bool>,
/// Enable auto-sync
#[arg(long)]
auto_sync: Option<bool>,
},
/// 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,
},
}

View file

@ -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<String>,
handle: Option<String>,
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
theme: Option<String>,
language: Option<String>,
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<String>,
handle: Option<String>,
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
theme: Option<String>,
language: Option<String>,
notifications: Option<bool>,
auto_sync: Option<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)?;
// 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(())
}
}

View file

@ -10,11 +10,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())

View file

@ -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");
}