diff --git a/Cargo.lock b/Cargo.lock index 8ba2074..7f17d63 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", ] @@ -630,10 +702,11 @@ dependencies = [ [[package]] name = "sharenet-passport" -version = "0.1.0" +version = "0.2.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 738f900..25f0625 100644 --- a/libs/sharenet-passport/Cargo.toml +++ b/libs/sharenet-passport/Cargo.toml @@ -1,11 +1,15 @@ [package] name = "sharenet-passport" -version = "0.1.0" +version = "0.2.0" +publish = ["sharenet-sh-forgejo"] # Set this to whichever Cargo registry you are publishing to edition = "2021" description = "Core library for Sharenet Passport creation and management" -authors = ["Your Name "] -license = "MIT OR Apache-2.0" +authors = ["Continuist "] +license = "CC-BY-NC-SA-4.0" repository = "https://git.sharenet.sh/your-org/sharenet-passport" +readme = "README.md" +keywords = ["cryptography", "identity", "passport", "sharenet"] +categories = ["cryptography", "authentication"] [dependencies] bip39 = "2.1" @@ -20,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 @@ -31,7 +37,4 @@ alloc = [] # No-std with alloc support wasm = ["alloc"] # WASM target support [dev-dependencies] -tempfile = "3.8" - -[publish] -registry = "sharenet" \ No newline at end of file +tempfile = "3.8" \ No newline at end of file diff --git a/libs/sharenet-passport/README.md b/libs/sharenet-passport/README.md new file mode 100644 index 0000000..51b936d --- /dev/null +++ b/libs/sharenet-passport/README.md @@ -0,0 +1,162 @@ +# Sharenet Passport Library + +A secure Rust library for creating and managing Sharenet Passport files (.spf) for decentralized identity management. + +## Features + +- **Secure Passport Creation**: Generate encrypted .spf files with BIP-39 mnemonic recovery phrases +- **Ed25519 Key Generation**: Cryptographically secure key derivation and signing +- **Recovery Support**: Import passports from recovery phrases or existing .spf files +- **Export & Re-encrypt**: Export passports with new passwords +- **Message Signing**: Sign messages using your passport's private key +- **Security First**: Zeroize memory management and secure file encryption +- **WASM Support**: Compatible with web applications via WebAssembly + +## Installation + +### From Private Registry + +```toml +[dependencies] +sharenet-passport = { version = "0.1.0", registry = "sharenet-sh-forgejo", features = ["std"] } +``` + +### For WASM Projects + +```toml +[dependencies] +sharenet-passport = { version = "0.1.0", registry = "sharenet-sh-forgejo", features = ["wasm"] } +``` + +## Usage + +### Creating a New Passport + +```rust +use sharenet_passport::{ + application::use_cases::CreatePassportUseCase, + infrastructure::{Bip39MnemonicGenerator, Ed25519KeyDeriver, XChaCha20FileEncryptor, FileSystemStorage}, +}; + +let use_case = CreatePassportUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, +); + +let (passport, recovery_phrase) = use_case.execute("your-password", "passport.spf")?; + +println!("Public Key: {:?}", passport.public_key()); +println!("DID: {}", passport.did().as_str()); +println!("Recovery Phrase: {}", recovery_phrase.to_string()); +``` + +### Importing from Recovery Phrase + +```rust +use sharenet_passport::{ + application::use_cases::ImportFromRecoveryUseCase, + infrastructure::{Bip39MnemonicGenerator, Ed25519KeyDeriver, XChaCha20FileEncryptor, FileSystemStorage}, +}; + +let use_case = ImportFromRecoveryUseCase::new( + Bip39MnemonicGenerator, + Ed25519KeyDeriver, + XChaCha20FileEncryptor, + FileSystemStorage, +); + +let recovery_words = vec!["word1".to_string(), "word2".to_string(), /* ... 24 words */]; +let passport = use_case.execute(&recovery_words, "new-password", "recovered-passport.spf")?; +``` + +### Signing Messages + +```rust +use sharenet_passport::{ + application::use_cases::{ImportFromFileUseCase, SignCardUseCase}, + infrastructure::{XChaCha20FileEncryptor, FileSystemStorage}, +}; + +// Import passport from file +let import_use_case = ImportFromFileUseCase::new( + XChaCha20FileEncryptor, + FileSystemStorage, +); + +let passport = import_use_case.execute("passport.spf", "password", None)?; + +// Sign message +let sign_use_case = SignCardUseCase::new(); +let signature = sign_use_case.execute(&passport, "Hello, Sharenet!")?; +``` + +## Architecture + +Built with Clean Architecture principles: + +- **Domain Layer**: Core entities (Passport, RecoveryPhrase, PublicKey, etc.) and traits +- **Application Layer**: Use cases (CreatePassport, ImportFromRecovery, SignCard, etc.) +- **Infrastructure Layer**: Crypto implementations, file storage + +## Feature Flags + +- `std` (default): Standard library support for CLI and server applications +- `wasm`: WebAssembly support for web applications +- `alloc`: No-std with allocator support + +## Security Features + +- **XChaCha20-Poly1305**: Authenticated encryption for file security +- **HKDF-SHA256**: Key derivation from passwords +- **Zeroize**: Secure memory wiping for sensitive data +- **BIP-39**: Standard mnemonic generation and validation +- **Ed25519**: Cryptographically secure signing + +## File Format (.spf) + +Sharenet Passport Files (.spf) are encrypted containers that store: + +- **Encrypted Seed**: The master seed encrypted with XChaCha20-Poly1305 +- **Public Key**: Your Ed25519 public key +- **DID**: Your Decentralized Identifier +- **Metadata**: Creation timestamp, version, and encryption parameters + +## Development + +### Running Tests + +```bash +# Run all tests +cargo test + +# Test specific features +cargo test --features std +cargo test --features wasm +``` + +### Building for WASM + +```bash +# Install wasm-pack if needed +cargo install wasm-pack + +# Build for web +wasm-pack build --target web --features wasm +``` + +## License + +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. + +You are free to: +- **Share** — copy and redistribute the material in any medium or format +- **Adapt** — remix, transform, and build upon the material + +Under the following terms: +- **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. +- **NonCommercial** — You may not use the material for commercial purposes. +- **ShareAlike** — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. + +To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/ \ No newline at end of file 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 a845907..f296efc 100644 --- a/libs/sharenet-passport/src/infrastructure/crypto_test.rs +++ b/libs/sharenet-passport/src/infrastructure/crypto_test.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use super::*; use crate::domain::entities::*; use crate::domain::traits::{MnemonicGenerator, KeyDeriver, FileEncryptor}; use crate::{Bip39MnemonicGenerator, Ed25519KeyDeriver, XChaCha20FileEncryptor}; @@ -22,7 +21,7 @@ mod tests { let generator = Bip39MnemonicGenerator; // This is a valid test mnemonic - let valid_words = vec![ + let _valid_words = vec![ "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), "abandon".to_string(), @@ -64,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"); @@ -75,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()); @@ -91,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"); @@ -112,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 @@ -126,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 1a42c45..916be53 100644 --- a/libs/sharenet-passport/src/infrastructure/storage_test.rs +++ b/libs/sharenet-passport/src/infrastructure/storage_test.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use super::*; use tempfile::NamedTempFile; use crate::domain::traits::FileStorage; use crate::{FileSystemStorage, PassportFile}; @@ -19,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/sharenet_spec.md b/sharenet_spec.md index 6f5bc60..0f65349 100644 --- a/sharenet_spec.md +++ b/sharenet_spec.md @@ -7,8 +7,8 @@ This document specifies a decentralized protocol for the creation, distribution, This protocol assumes three fundamental building blocks in addition to Cards: - **Passports**: User-held identifiers derived from cryptographic seeds. -- **Nodes**: Servers that maintain membership, relay Cards, and enforce network policies. -- **Networks**: Collections of nodes and users defined by a shared Genesis Document. +- **Hubs**: Servers that maintain membership, relay Cards, and enforce network policies. +- **Universes**: Collections of hubs and users defined by a shared self-describing universe identifier. ## 2. Terminology @@ -23,9 +23,9 @@ This protocol assumes three fundamental building blocks in addition to Cards: - **Distribution**: Where the Card may be transmitted. - **Permanent public Card**: A Card with no encryption and no revocation path. Immutable. - **Passport**: A user-held self-sovereign identifier derived from a mnemonic seed. -- **Node**: A server implementing this protocol and providing membership and relay functions. -- **Network**: A collection of nodes and users sharing a Genesis Document. -- **Genesis Document**: Immutable initial configuration defining a network. +- **Hub**: A server implementing this protocol and providing membership and relay functions. +- **Universe**: A collection of hubs and users sharing a self-describing universe identifier. +- **Universe Identifier (univ_id)**: A human-readable DID that encodes both a human-readable name and unique identifier in the format `u::`. ## 3. Cards @@ -35,7 +35,7 @@ A Card is a CBOR map wrapped in a COSE_Sign1 envelope. Fields: - `ver`: Protocol version. - `type`: MUST be `"card"`. -- `net`: Network identifier. +- `univ`: Universe identifier. - `rid`: Unique Card identifier. - `ts`: Creation timestamp. - `reqs`: Array of capability IDs required to interpret the Card. @@ -51,8 +51,8 @@ A Card is a CBOR map wrapped in a COSE_Sign1 envelope. Fields: The policy capsule is an encrypted CBOR map. Fields: -- `vis`: `"public" | "direct" | "node" | "trustset" | "group"`. -- `dist`: `"to_recipients_only" | "this_node_only" | "trusted_nodes" | "public"`. +- `vis`: `"public" | "direct" | "hub" | "federation" | "group"`. +- `dist`: `"recipients" | "hub" | "federation" | "public"`. - `keying`: HPKE encapsulations, MLS group material, or gate instructions for CEK recovery. - `flags`: e.g. `permanent_public`. - `exp`, `ttl`: Optional expiry or validity constraints. @@ -61,7 +61,7 @@ The policy capsule is an encrypted CBOR map. Fields: ### 3.3 Payload -- Private Cards: `payload` = AEAD(CEK, plaintext_card_body, AAD={net, rid, policy_hash}) +- Private Cards: `payload` = AEAD(CEK, plaintext_card_body, AAD={univ, rid, policy_hash}) - Public Cards: `payload` = plaintext_card_body - `payload_hash` = SHA-256(payload) @@ -93,7 +93,7 @@ A bundle spec lists `members` (cap IDs). Bundles MUST expand deterministically i ### 4.4 Negotiation -- Nodes advertise supported caps at `/.well-known/node-caps`. +- Hubs advertise supported caps at `/.well-known/hub-caps`. - Cards include `reqs` for enforcement, `prov` for provenance. - Consumers MUST reject Cards if any `reqs` are unsupported. @@ -101,15 +101,15 @@ A bundle spec lists `members` (cap IDs). Bundles MUST expand deterministically i - **Public**: No encryption. MAY be flagged permanent. - **Direct**: Encrypted CEK to a single recipient (HPKE). -- **Node**: CEK encrypted to a node’s group key or gate. -- **Trustset**: CEK encrypted to each trusted node’s group key. +- **Hub**: CEK encrypted to a hub’s group key or gate. +- **Federation**: CEK encrypted to each trusted hub’s group key. - **Group**: CEK encrypted to an MLS group. Roster MAY remain private. ## 6. Distribution Modes -- **to_recipients_only**: Honest nodes forward only to recipients. -- **this_node_only**: Not gossiped beyond node. -- **trusted_nodes**: Forwarded only to trustset. +- **recipients**: Honest hubs forward only to recipients. +- **hub**: Not gossiped beyond hub. +- **federation**: Forwarded only to federation. - **public**: Freely gossiped. Encryption still protects confidentiality. Distribution policies MAY be encrypted inside the policy capsule. @@ -124,10 +124,10 @@ Distribution policies MAY be encrypted inside the policy capsule. ## 8. Security Considerations - All hashes MUST be computed over canonical CBOR. -- Policy capsule MUST bind `{net, rid, payload_hash}` in AEAD AAD. +- Policy capsule MUST bind `{univ, rid, payload_hash}` in AEAD AAD. - Bundles MUST be expanded before enforcement. - Unknown required caps MUST cause rejection. -- Distribution enforcement relies on honest nodes; confidentiality relies on crypto. +- Distribution enforcement relies on honest hubs; confidentiality relies on crypto. - Padding SHOULD be used to obscure audience sizes. ## 9. Extensibility @@ -168,12 +168,12 @@ The public key is used to generate the DID and sign Cards. ### 11.3 Usage - Cards are signed with the Passport’s Ed25519 private key. - Verification uses the DID → public key mapping. -- Users MAY belong to multiple networks with the same Passport. +- Users MAY NOT belong to multiple universes with the same Passport. -## 12. Nodes +## 12. Hubs ### 12.1 Definition -A **Node** is a server implementing the protocol, typically written in Rust. +A **Hub** is a server implementing the protocol, typically written in Rust. It provides: - Membership management (accepting/revoking Passports). @@ -182,45 +182,59 @@ It provides: - Optional Key Gate services for strong revocation. ### 12.2 Membership -- A user joins a Node by presenting a signed request with their Passport public DID. -- The Node MAY issue a Verifiable Credential (VC) attesting to membership. -- Users MAY leave a Node by revoking their VC locally. -- Nodes MAY gossip membership VCs to peers. +- A user joins a Hub by presenting a signed request with their Passport public DID. +- The Hub MAY issue a Verifiable Credential (VC) attesting to membership. +- Users MAY leave a Hub by revoking their VC locally. +- Hubs MAY gossip membership VCs to peers. ### 12.3 Federation -Nodes communicate using Cards. -Distribution policies determine which nodes are eligible to receive a Card. -Nodes MAY maintain trustsets to decide forwarding scopes. +Hubs communicate using Cards. +Distribution policies determine which hubs are eligible to receive a Card. +Hubs MAY maintain federations to decide forwarding scopes. -## 13. Networks +## 13. Universes -### 13.1 Genesis Document -Each network begins with a **Genesis Document** that defines: +### 13.1 Universe Identifier (univ_id) +Each universe is identified by a **human-readable DID** that encodes both a human-readable name and a unique identifier. -- `net_id`: Unique network identifier. -- `genesis_ts`: Timestamp of creation. -- `founders`: Initial node and user DIDs. -- `bootstrap_caps`: Minimal capability set required for participation. -- `initial_policies`: Distribution and trust defaults. +**Format:** +``` +u:: +``` -The Genesis Document is signed by the founders and distributed to all participants. -It MUST be immutable. Any update creates a **new network**. +**Components:** +- `u:` - Prefix identifying this as a universe identifier +- `` - UTF-8 encoded universe name (any length) +- `` - UUIDv7 identifier (RFC 4122 format) + +**Constraints:** +- The name MUST be a valid UTF-8 string +- Special characters in the name SHOULD be URL-encoded if they conflict with the format +- The UUIDv7 MUST be generated according to RFC 4122 +- The entire string MUST be treated as case-sensitive + +**Example:** +``` +u:Test Universe:018f1234-5678-90ab-cdef-123456789abc +``` + +The `univ_id` is used directly for passport creation and universe identification. No separate Genesis Document is required. ### 13.2 Participation -- Users MAY join multiple networks. -- Nodes MAY serve one or more networks, but each Card is bound to a single `net_id`. -- Networks are sovereign: no privileged global operator exists. +- Users MAY join multiple universes. +- Hubs MAY serve one or more universe, but each Card is bound to a single `univ_id`. +- Universes are sovereign: no privileged global operator exists. ### 13.3 Migration -- Cards and Passports MAY move between networks. -- Migration of state between networks requires new credentials or attestations. -- Cards created under one `net_id` remain bound to that `net_id` forever. +- Cards and Passports MAY NOT move between universes. +- Passports created under one `univ_id` remain bound to that `univ_id` forever. +- Cards created under one `univ_id` remain bound to that `univ_id` forever. ## 14. Security Model & Assumptions The protocol operates under the following assumptions and goals: -- **Honest-but-curious nodes**: Nodes may relay Cards faithfully but attempt to learn additional metadata. +- **Honest-but-curious hubs**: Hubs may relay Cards faithfully but attempt to learn additional metadata. - **Malicious peers**: Adversaries may forge, replay, strip capabilities, or attempt downgrade attacks. - **Passive observers**: Adversaries may monitor network traffic but lack access to private keys. - **Compromised devices**: If a user’s Passport seed is stolen, their identity is compromised until rotated. @@ -236,7 +250,7 @@ Implementations MUST use the following algorithms unless explicitly negotiated b - **Hashing**: SHA-256 for `content_hash`, `policy_ref`, and capability IDs. - **KDFs**: HKDF-SHA256 for deriving CEKs and KEKs. - **Randomness**: CEKs MUST be generated with a CSPRNG; nonces MUST NOT repeat. -- **AAD binding**: `{net, rid, content_hash, policy_ref}` MUST be included as Additional Authenticated Data in all AEAD operations. +- **AAD binding**: `{univ, rid, content_hash, policy_ref}` MUST be included as Additional Authenticated Data in all AEAD operations. ## 16. Key Management & Rotation @@ -249,24 +263,24 @@ Implementations MUST use the following algorithms unless explicitly negotiated b ### 16.2 Rotation - Users MAY rotate Passport keys by generating a new keypair from the seed with a different derivation path. - DID key rotation certificates MAY be issued: signed by both old and new keys, linking continuity. -- Nodes and verifiers MUST accept a chain of rotation certificates when validating historical Cards. +- Hubs and verifiers MUST accept a chain of rotation certificates when validating historical Cards. ### 16.3 Revocation - Revocation of Cards MAY occur via deletion of KEKs (crypto-shred) or via refusal to release CEKs (online gate). -- Membership credentials (VCs) MUST include status lists or revocation registries to support user exit from nodes. +- Membership credentials (VCs) MUST include status lists or revocation registries to support user exit from hubs. ## 17. Policy Capsule Wire Format ### 17.1 Encoding - The policy capsule is encoded as **CBOR**, then wrapped in **COSE_Encrypt0**. - Key encapsulation MAY use **HPKE** (per recipient) or **MLS** (per group). -- The capsule MUST be integrity-protected with AEAD; AAD MUST include `{net, rid, payload_hash}`. +- The capsule MUST be integrity-protected with AEAD; AAD MUST include `{univ, rid, payload_hash}`. ### 17.2 Structure (plaintext before encryption) ```cbor { - "vis": "public" | "direct" | "node" | "trustset" | "group", - "dist": "to_recipients_only" | "this_node_only" | "trusted_nodes" | "public", + "vis": "public" | "direct" | "hub" | "federation" | "group", + "dist": "recipients" | "hub" | "federation" | "public", "keying": { "hpke": [ { "kid": , "enc": }, ... ], "mls": { "group_id": bstr, "epoch": int, "welcome": bstr }, @@ -299,13 +313,13 @@ Endpoints: - `POST /decrypt` → returns plaintext stream of Card body. Rules: -- Requests MUST carry an **audience binding** (`rid`, `net`). +- Requests MUST carry an **audience binding** (`rid`, `univ`). - Gates MUST enforce **TTL** on issued tokens. - Rate limits and replay protection MUST be applied. - Revocation = gate refuses further issuance. ### 18.2 Crypto-Shred -- CEK is wrapped under a KEK stored at a node or device. +- CEK is wrapped under a KEK stored at a hub or device. - Revocation = securely delete KEK. - Clients encountering missing KEK MUST treat the Card as revoked. @@ -313,12 +327,12 @@ Rules: - A CRL is a signed CBOR array of revoked `rid`s with metadata: ```cbor { - "net": , + "univ": , "ts": , "revoked": [ , , ... ] } ``` -- CRLs MUST be signed by the issuer (user or node). +- CRLs MUST be signed by the issuer (user or hub). - Clients MUST hide revoked Cards even if cached. - Freshness MUST be enforced (CRL expiry or versioning). @@ -326,14 +340,14 @@ Rules: ## 19. Distribution Rules & Relay Behavior -### 19.1 Enforcement by Honest Nodes -- **to_recipients_only**: forward only if node is itself a recipient or directly connected to one. -- **this_node_only**: MUST NOT forward beyond local storage. -- **trusted_nodes**: MAY forward only to nodes in the local trustset. +### 19.1 Enforcement by Honest Hubs +- **recipients**: forward only if hub is itself a recipient or directly connected to one. +- **hub**: MUST NOT forward beyond local storage. +- **federation**: MAY forward only to hubs in the local federation. - **public**: MAY forward freely. ### 19.2 Logging -- Nodes SHOULD log distribution violations (attempts to forward beyond scope). +- Hubs SHOULD log distribution violations (attempts to forward beyond scope). - Logs MUST contain only hashes (`rid`, `content_hash`), not plaintext. ### 19.3 Confidentiality Interaction @@ -342,14 +356,14 @@ Rules: - Even if a Card leaks beyond scope, non-recipients cannot decrypt. ### 19.4 Error Signaling -- If a node refuses to forward a Card due to distribution rules, it SHOULD return an error code: +- If a hub refuses to forward a Card due to distribution rules, it SHOULD return an error code: - `err_dist_scope` = distribution violation - `err_recipient_only` = not a recipient ## 20. Transport & Gossip Protocol ### 20.1 Discovery -- Nodes MAY be discovered via: +- Hubs MAY be discovered via: - Static configuration files or bootstrap lists. - Distributed Hash Table (DHT) lookups. - Rendezvous services (non-privileged). @@ -361,13 +375,13 @@ Rules: ### 20.3 Deduplication and Replay Protection - Every Card has a unique `rid`. -- Nodes MUST maintain a deduplication cache to avoid re-processing the same Card. +- Hubs MUST maintain a deduplication cache to avoid re-processing the same Card. - Replay attacks MUST be prevented by rejecting Cards with duplicate `rid`s or stale timestamps. ### 20.4 Idempotency and Limits - All Card handling MUST be idempotent: re-processing MUST NOT change system state. - Maximum Card size SHOULD be capped (e.g. 1 MB). -- Nodes MAY reject or truncate oversized payloads. +- Hubs MAY reject or truncate oversized payloads. --- @@ -376,7 +390,7 @@ Rules: ### 21.1 RIDs - `rid` is the globally unique identifier for a Card. - Recommended format: **UUIDv7** (time-ordered, random component). -- Collisions are cryptographically improbable; nodes MUST treat duplicates as replay attempts. +- Collisions are cryptographically improbable; hubs MUST treat duplicates as replay attempts. ### 21.2 Timestamps - Each Card includes a creation timestamp (`ts`). @@ -402,17 +416,17 @@ Rules: ### 22.2 Signatures and Trust - Packages SHOULD be signed by their authors. -- Node admins MAY define local trust policies (e.g., trusted keyrings). +- Hub admins MAY define local trust policies (e.g., trusted keyrings). - Unsigned or untrusted packages MAY be rejected. ### 22.3 Module Execution - Validation logic MAY be provided as **WebAssembly (WASM) modules**. -- Nodes MUST sandbox modules with: +- Hubs MUST sandbox modules with: - CPU, memory, and time limits. - Deterministic APIs for schema validation and normalization. ### 22.4 Installation -- Packages MAY be installed automatically from peers or manually by node admins. +- Packages MAY be installed automatically from peers or manually by hub admins. - A CLI MAY be provided: ```bash syspm install @@ -451,25 +465,25 @@ syspm install --- -## 24. Node Roles & Trustsets +## 24. Hub Roles & Federations -### 24.1 Node Roles -Nodes MAY declare one or more roles: +### 24.1 Hub Roles +Hubs MAY declare one or more roles: - **archive**: long-term storage of all Cards. - **relay**: ephemeral forwarder, minimal storage. - **light**: partial state, relies on peers for history. -Roles MAY be declared in Node metadata and MAY guide peer selection. +Roles MAY be declared in Hub metadata and MAY guide peer selection. -### 24.2 Trustsets -- A trustset is a locally defined list of nodes considered trustworthy by a given node. -- Trustsets MAY be defined by DID, fingerprint, or signed config. -- Trustsets MUST be rotatable without changing the Genesis Document. -- Distribution rules (e.g. “trusted_nodes”) are enforced using trustsets. +### 24.2 Federations +- A federation is a locally defined list of hubs considered trustworthy by a given hub. +- Federations MAY be defined by DID, fingerprint, or signed config. +- Federations MUST be rotatable without changing the universe identifier. +- Distribution rules (e.g. “federation”) are enforced using federations. ### 24.3 Rotation -- Trustsets SHOULD support rotation via signed update lists. -- Nodes MAY advertise trustset digests to peers. +- Federations SHOULD support rotation via signed update lists. +- Hubs MAY advertise federation digests to peers. - Discrepancies MAY be logged for audit. --- @@ -482,56 +496,47 @@ A membership Verifiable Credential (VC) MUST contain: { "vc": { "sub": , - "iss": , + "iss": , "claim": "membership", - "net": , + "univ": , "ts": , "exp": }, - "sig": + "sig": } ``` ### 25.2 Status Lists -- Nodes MAY maintain status lists indicating active, revoked, or suspended memberships. +- Hubs MAY maintain status lists indicating active, revoked, or suspended memberships. - Status lists MUST be signed and periodically refreshed. - Clients MUST check membership status before validating Cards. ### 25.3 Verification - To validate a Card: - Verify the Card signature against the user’s DID. - - Fetch the user’s membership VC for the target node/network. + - Fetch the user’s membership VC for the target hub/universe. - Check the VC’s signature, validity window, and status list entry. - Historical Cards MUST remain valid if the VC was active at the time of creation. -## 26. Network Genesis, Publication, and Forks +## 26. Universe Creation and Identification -### 26.1 Canonical Genesis -- The Genesis Document MUST be encoded in canonical CBOR. -- The `net_id` is computed as `SHA-256` of the canonical bytes. -- Genesis MUST include: - - `net_id`, `genesis_ts`, founders (node/user DIDs), bootstrap capabilities, and policies. -- Genesis is immutable. Any change results in a new `net_id` and thus a new network. +### 26.1 Universe Identifier Generation +- A universe is created by generating a **human-readable DID** +- The format MUST be: `u::` +- The name MUST be a valid UTF-8 string (any length) +- The UUIDv7 MUST be generated according to RFC 4122 +- Special characters in the name SHOULD be URL-encoded -### 26.2 Multi-Signature Policy -- Genesis SHOULD be co-signed by multiple founders. -- Minimum signature threshold MUST be specified (e.g., 2-of-3). -- Verifiers MUST reject Genesis docs without sufficient signatures. +### 26.2 Name Extraction +- Given any valid `univ_id`, the human-readable name MUST be extractable +- Implementations MUST parse the format to retrieve the name for display +- The name is permanently embedded and cannot be changed +- URL-encoded characters MUST be decoded when extracting the name -### 26.3 Publication Channels -- Genesis MAY be published via: - - Static files. - - Peer-to-peer distribution. - - Content-addressed storage (IPFS, Git, etc.). - -### 26.4 No-Tombstone Rule -- Once published, a Genesis Document MUST NOT be deleted. -- Networks have permanence; dissolution only occurs if all peers stop participating. - -### 26.5 Fork Handling -- If conflicting successor Genesis docs appear, each creates a distinct new `net_id`. -- Participants MAY choose which fork to join. -- Forks MUST NOT silently overwrite each other. +### 26.3 Uniqueness and Independence +- Each `univ_id` is globally unique due to the UUIDv7 component +- There is no concept of "forks" since each universe is independent +- Participants MAY join any universe by its `univ_id` --- @@ -586,7 +591,7 @@ A membership Verifiable Credential (VC) MUST contain: - Padding helps obscure audience size and plaintext length. ### 28.3 Cover Traffic -- Nodes MAY generate synthetic Cards with random content to obscure traffic patterns. +- Hubs MAY generate synthetic Cards with random content to obscure traffic patterns. - Cover traffic MUST be clearly marked so peers can discard after validation. ### 28.4 CEK Caching @@ -601,7 +606,7 @@ A membership Verifiable Credential (VC) MUST contain: ## 29. Abuse & Resource Controls ### 29.1 Rate Limits -- Nodes MUST enforce per-peer request limits to prevent denial of service. +- Hubs MUST enforce per-peer request limits to prevent denial of service. - Suggested baseline: 100 requests/minute per peer, adjustable by implementation. ### 29.2 Proof-of-Work or Postage @@ -620,11 +625,11 @@ A membership Verifiable Credential (VC) MUST contain: - Modules exceeding budgets MUST be terminated. ### 29.5 Signature Verification Quotas -- Nodes SHOULD limit the number of signature verifications per peer per unit time. +- Hubs SHOULD limit the number of signature verifications per peer per unit time. - Helps protect against CPU exhaustion attacks. ### 29.6 Ban Lists -- Nodes MAY maintain local ban lists of misbehaving peers. +- Hubs MAY maintain local ban lists of misbehaving peers. - Ban lists SHOULD be rotatable and MAY be shared among trusted peers. --- @@ -640,7 +645,7 @@ Implementations MUST support the following error codes for interoperability: - `err_revoked`: Card has been revoked. - `err_gate_denied`: Gate refused to provide CEK. - `err_dist_scope`: Distribution rule violation. -- `err_recipient_only`: Node not an intended recipient. +- `err_recipient_only`: Hub not an intended recipient. - `err_size_exceeded`: Payload too large. ### 30.2 Error Format @@ -710,7 +715,7 @@ Implementations MUST validate against these vectors to ensure interoperability. - Range fetch MUST allow partial retrieval and recombination. ### 32.4 Retention and Garbage Collection -- Nodes MAY garbage-collect old payloads not recently requested. +- Hubs MAY garbage-collect old payloads not recently requested. - Garbage collection policies MUST NOT break integrity: missing payloads must be detectable via hashes. - Permanent public Cards SHOULD be retained indefinitely. @@ -719,7 +724,7 @@ Implementations MUST validate against these vectors to ensure interoperability. ## 33. Observability & Audit ### 33.1 Minimal Logging -- Nodes SHOULD log only: +- Hubs SHOULD log only: - `rid` - `cap_ids` - error codes @@ -730,13 +735,13 @@ Implementations MUST validate against these vectors to ensure interoperability. - Only hashed identifiers may be retained for correlation. ### 33.3 Migration Receipts -- When a Card is re-issued due to schema or protocol migration, nodes MAY attach a signed **migration receipt**: +- When a Card is re-issued due to schema or protocol migration, hubs MAY attach a signed **migration receipt**: ```cbor { "old_rid": , "new_rid": , "ts": , - "sig": + "sig": } ``` - Receipts provide an auditable trail for backward compatibility. @@ -760,7 +765,7 @@ Implementations MUST validate against these vectors to ensure interoperability. ### 34.3 Collision Handling - If two aliases collide: - - Node admins MAY configure preferred mappings. + - Hub admins MAY configure preferred mappings. - UIs SHOULD disambiguate by showing both alias and `cap_id`. ### 34.4 Example @@ -775,7 +780,7 @@ Implementations MUST validate against these vectors to ensure interoperability. ### 35.1 Purpose - Security bulletins provide a mechanism to urgently disable compromised capabilities, algorithms, or schema packages. -- Bulletins are signed notices distributed across the network. +- Bulletins are signed notices distributed across the universe. ### 35.2 Structure ```cbor @@ -789,7 +794,7 @@ Implementations MUST validate against these vectors to ensure interoperability. ``` ### 35.3 Issuers -- Bulletins MAY be issued by capability authors, network founders, or node operators. +- Bulletins MAY be issued by capability authors or hub operators. - Clients MUST verify the issuer’s signature against a trusted keyring. ### 35.4 Client Behavior @@ -827,7 +832,7 @@ Implementations MUST validate against these vectors to ensure interoperability. - Consumers MUST reject Cards that require unknown or deprecated `cap_id`s. ### 36.5 Adapters -- Nodes MAY provide **adapters** to transform Cards from older schema versions to newer ones. +- Hubs MAY provide **adapters** to transform Cards from older schema versions to newer ones. - Adapters MUST be deterministic and SHOULD be distributed as signed schema packages. - Consumers MUST record the use of adapters in audit logs. @@ -837,7 +842,7 @@ Implementations MUST validate against these vectors to ensure interoperability. { "ver": 1, "type": "card", - "net": "net:prod:z9…", + "univ": "u:Production Universe:018f1234-5678-90ab-cdef-123456789abc", "rid": "0x12b…", "ts": 1738123456, "reqs": ["cap:card/v1@…"], @@ -858,7 +863,7 @@ Implementations MUST validate against these vectors to ensure interoperability. { "ver": 1, "type": "card", - "net": "net:prod:z9…", + "univ": "u:Production Universe:018f1234-5678-90ab-cdef-123456789abc", "rid": "0x44a…", "ts": 1738126789, "reqs": ["cap:card/v2@…","cap:hpke-x25519-xc20p@…"], @@ -951,7 +956,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ### 3.2 Schema Descriptors **Purpose:** Operator-friendly pointer to install a module out-of-band. -**Security:** MUST be **signed**. Node validates artifact hash before load. +**Security:** MUST be **signed**. Hub validates artifact hash before load. ```cbor { @@ -997,32 +1002,32 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen } ``` -### 3.4 Node Capability Manifests -**Purpose:** What a node supports (caps, roles). +### 3.4 Hub Capability Manifests +**Purpose:** What a hub supports (caps, roles). -**Security:** SHOULD be **signed** and available at `/.well-known/node-caps`. +**Security:** SHOULD be **signed** and available at `/.well-known/hub-caps`. ```cbor { - "schema": "node-caps/v1", - "net": tstr, + "schema": "hub-caps/v1", + "univ": tstr, "caps_supported": [ tstr, ... ], "roles": [ "relay", "archive", "light" ] } ``` ### 3.5 Membership Credentials (VC) -**Purpose:** “Alice @ Node B” assertion for a given network. +**Purpose:** “Alice @ Hub B” assertion for a given universe. -**Security:** MUST be **signed** by the issuing node. +**Security:** MUST be **signed** by the issuing hub. ```cbor { "schema": "membership-vc/v1", "vc": { "sub": tstr, ; user DID - "iss": tstr, ; node DID - "net": tstr, + "iss": tstr, ; hub DID + "univ": tstr, "claim": "membership", "ts": uint, "exp": uint @@ -1038,7 +1043,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ```cbor { "schema": "vc-status/v1", - "net": tstr, + "univ": tstr, "ts": uint, "entries": [ { "did": tstr, "status": "active" | "revoked" | "suspended", "exp": uint } ] } @@ -1075,28 +1080,28 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ### 3.9 Gate Tokens / Requests (Optional) **Purpose:** Short-lived authorization to retrieve CEKs or stream decrypts. -**Security:** MUST be **signed**, bound to `{rid, net}`, have short TTL. +**Security:** MUST be **signed**, bound to `{rid, univ}`, have short TTL. ```cbor { "schema": "gate-token/v1", "rid": bstr, - "net": tstr, + "univ": tstr, "aud": bstr, "exp": uint } ``` -### 3.10 Trustset Updates -**Purpose:** Rotate which nodes are considered trusted for forwarding. +### 3.10 Federation Updates +**Purpose:** Rotate which hubs are considered trusted for forwarding. **Security:** MUST be **signed** by local operator key. ```cbor { - "schema": "trustset/v1", + "schema": "federation/v1", "version": uint, - "entries": [ { "node": tstr, "op": "add" | "remove" } ] + "entries": [ { "hub": tstr, "op": "add" | "remove" } ] } ``` @@ -1154,7 +1159,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ```cbor { "schema": "crl/v1", - "net": tstr, + "univ": tstr, "ts": uint, "revoked": [ bstr, ... ] ; list of rid } @@ -1209,7 +1214,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ## 5. Transport Considerations - All control-plane and governance objects MAY be gossiped like Cards or fetched on demand by ID (content-addressed). -- Nodes SHOULD cache verified specs/packages and bulletins. +- Hubs SHOULD cache verified specs/packages and bulletins. - Deduplicate by `(schema, primary id field)` to avoid reprocessing. - Objects MUST be small (typically <64KB), except blob metadata. @@ -1217,7 +1222,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen ## 6. Security & Validation Rules (Normative) -- **Signature verification:** Required for all objects that change validation or trust (schema packages, caps, VCs, status lists, deprecations, bulletins, trustsets). +- **Signature verification:** Required for all objects that change validation or trust (schema packages, caps, VCs, status lists, deprecations, bulletins, federations). - **Determinism:** Validators/normalizers MUST be pure and deterministic; no network/file I/O. - **Sandboxing:** Any executable validation logic MUST run in a WASM sandbox under strict CPU/mem/time budgets. - **Hash binding:** Where objects reference Cards or payloads, they MUST use `rid` or `content_hash` and verify before acting. 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