Compare commits

...

6 commits

19 changed files with 2814 additions and 208 deletions

149
Cargo.lock generated
View file

@ -116,6 +116,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "cfg-if"
version = "1.0.3"
@ -146,6 +152,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half 2.7.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -218,6 +251,12 @@ dependencies = [
"libc",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@ -363,6 +402,17 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -414,6 +464,16 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "js-sys"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.176"
@ -426,6 +486,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "once_cell"
version = "1.21.3"
@ -571,6 +637,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.27"
@ -593,7 +665,7 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
dependencies = [
"half",
"half 1.8.3",
"serde",
]
@ -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"

View file

@ -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 <your.email@example.com>"]
license = "MIT OR Apache-2.0"
authors = ["Continuist <continuist02@gmail.com>"]
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"
tempfile = "3.8"

View file

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

View file

@ -2,6 +2,7 @@ use crate::domain::entities::*;
use crate::domain::traits::*;
use crate::application::error::ApplicationError;
use ed25519_dalek::Signer;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct CreatePassportUseCase<MG, KD, FE, FS>
where
@ -39,6 +40,7 @@ where
pub fn execute(
&self,
univ_id: &str,
password: &str,
output_path: &str,
) -> Result<(Passport, RecoveryPhrase), ApplicationError> {
@ -48,10 +50,10 @@ where
.generate()
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to generate mnemonic: {}", e.into())))?;
// Derive seed from mnemonic
// Derive seed from mnemonic and universe
let seed = self
.key_deriver
.derive_from_mnemonic(&recovery_phrase)
.derive_from_mnemonic(&recovery_phrase, univ_id)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to derive seed: {}", e.into())))?;
// Derive keys from seed
@ -65,6 +67,7 @@ where
seed,
public_key,
private_key,
univ_id.to_string(),
);
// Encrypt and save file
@ -75,6 +78,8 @@ where
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
@ -122,6 +127,7 @@ where
pub fn execute(
&self,
univ_id: &str,
recovery_words: &[String],
password: &str,
output_path: &str,
@ -133,10 +139,10 @@ where
let recovery_phrase = RecoveryPhrase::new(recovery_words.to_vec());
// Derive seed from mnemonic
// Derive seed from mnemonic and universe
let seed = self
.key_deriver
.derive_from_mnemonic(&recovery_phrase)
.derive_from_mnemonic(&recovery_phrase, univ_id)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to derive seed: {}", e.into())))?;
// Derive keys from seed
@ -150,6 +156,7 @@ where
seed,
public_key,
private_key,
univ_id.to_string(),
);
// Encrypt and save file
@ -160,6 +167,8 @@ where
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
@ -208,18 +217,20 @@ where
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to load file: {}", e.into())))?;
// Decrypt file
let (seed, public_key, private_key) = self
let (seed, public_key, private_key, user_profiles) = self
.file_encryptor
.decrypt(&passport_file, password)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to decrypt file: {}", e.into())))?;
// Create passport (without storing recovery phrase)
let passport = Passport::new(
let mut passport = Passport::new(
seed,
public_key,
private_key,
passport_file.univ_id.clone(),
);
passport.user_profiles = user_profiles;
// Re-encrypt and save if output path provided
if let Some(output_path) = output_path {
@ -230,6 +241,8 @@ where
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to re-encrypt file: {}", e.into())))?;
@ -276,6 +289,8 @@ where
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
@ -306,10 +321,200 @@ impl SignCardUseCase {
.map_err(|_| ApplicationError::UseCaseError("Invalid private key length".to_string()))?
);
// Sign the message
let signature = signing_key.sign(message.as_bytes());
// Create universe-bound message to sign
let message_to_sign = format!("u:{}:{}", passport.univ_id, message);
// Sign the universe-bound message
let signature = signing_key.sign(message_to_sign.as_bytes());
// Return the signature as bytes
Ok(signature.to_bytes().to_vec())
}
}
pub struct CreateUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> CreateUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
pub fn new(file_encryptor: FE, file_storage: FS) -> Self {
Self {
file_encryptor,
file_storage,
}
}
pub fn execute(
&self,
passport: &mut Passport,
hub_did: Option<String>,
identity: UserIdentity,
preferences: UserPreferences,
password: &str,
file_path: &str,
) -> Result<(), ApplicationError> {
let profile = UserProfile::new(hub_did, identity, preferences);
passport.add_user_profile(profile)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to add user profile: {}", e)))?;
// Save updated passport
let passport_file = self
.file_encryptor
.encrypt(
&passport.seed,
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
self.file_storage
.save(&passport_file, file_path)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?;
Ok(())
}
}
pub struct UpdateUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> UpdateUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
pub fn new(file_encryptor: FE, file_storage: FS) -> Self {
Self {
file_encryptor,
file_storage,
}
}
pub fn execute(
&self,
passport: &mut Passport,
id: Option<&str>,
identity: UserIdentity,
preferences: UserPreferences,
password: &str,
file_path: &str,
) -> Result<(), ApplicationError> {
// Find existing profile by ID to preserve its ID and created_at
let id = id
.ok_or_else(|| ApplicationError::UseCaseError("Profile ID is required".to_string()))?;
let existing_profile = passport.user_profile_by_id(id)
.ok_or_else(|| ApplicationError::UseCaseError("User profile not found".to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| ApplicationError::UseCaseError(format!("Time error: {}", e)))?
.as_secs();
// Use existing hub_did (cannot change hub_did via update)
let profile = UserProfile {
id: existing_profile.id.clone(),
hub_did: existing_profile.hub_did.clone(),
identity,
preferences,
created_at: existing_profile.created_at,
updated_at: now,
};
passport.update_user_profile_by_id(id, profile)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to update user profile: {}", e)))?;
// Save updated passport
let passport_file = self
.file_encryptor
.encrypt(
&passport.seed,
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
self.file_storage
.save(&passport_file, file_path)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?;
Ok(())
}
}
pub struct DeleteUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
file_encryptor: FE,
file_storage: FS,
}
impl<FE, FS> DeleteUserProfileUseCase<FE, FS>
where
FE: FileEncryptor,
FS: FileStorage,
{
pub fn new(file_encryptor: FE, file_storage: FS) -> Self {
Self {
file_encryptor,
file_storage,
}
}
pub fn execute(
&self,
passport: &mut Passport,
id: Option<&str>,
password: &str,
file_path: &str,
) -> Result<(), ApplicationError> {
let id = id
.ok_or_else(|| ApplicationError::UseCaseError("Profile ID is required".to_string()))?;
passport.remove_user_profile_by_id(id)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to remove user profile: {}", e)))?;
// Save updated passport
let passport_file = self
.file_encryptor
.encrypt(
&passport.seed,
password,
&passport.public_key,
&passport.did,
&passport.univ_id,
&passport.user_profiles,
)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to encrypt file: {}", e.into())))?;
self.file_storage
.save(&passport_file, file_path)
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to save file: {}", e.into())))?;
Ok(())
}
}

View file

@ -1,6 +1,6 @@
#[cfg(test)]
mod tests {
use crate::application::use_cases::{CreatePassportUseCase, ImportFromRecoveryUseCase, ImportFromFileUseCase, ExportPassportUseCase, SignCardUseCase};
use crate::application::use_cases::{CreatePassportUseCase, ImportFromRecoveryUseCase, ImportFromFileUseCase, ExportPassportUseCase, SignCardUseCase, CreateUserProfileUseCase, UpdateUserProfileUseCase, DeleteUserProfileUseCase};
// Note: These domain entities are used indirectly through the use cases
use crate::infrastructure::crypto::{Bip39MnemonicGenerator, Ed25519KeyDeriver, XChaCha20FileEncryptor};
use crate::infrastructure::storage::FileSystemStorage;
@ -19,12 +19,15 @@ mod tests {
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (passport, recovery_phrase) = use_case.execute(password, file_path).unwrap();
let (passport, recovery_phrase) = use_case.execute("univ:test:create", password, file_path).unwrap();
// Verify passport structure
assert_eq!(recovery_phrase.words().len(), 24);
assert_eq!(passport.public_key().0.len(), 32);
assert!(passport.did().as_str().starts_with("did:sharenet:"));
// DID should have "p:" prefix followed by hex-encoded public key (66 characters for 32 bytes + "p:")
assert_eq!(passport.did().as_str().len(), 66);
assert!(passport.did().as_str().starts_with("p:"));
assert!(passport.did().as_str().chars().skip(2).all(|c| c.is_ascii_hexdigit()));
// Verify file was created
assert!(std::path::Path::new(file_path).exists());
@ -44,7 +47,7 @@ mod tests {
let file_path1 = temp_file1.path().to_str().unwrap();
let password = "test-password";
let (passport, recovery_phrase) = create_use_case.execute(password, file_path1).unwrap();
let (passport, recovery_phrase) = create_use_case.execute("univ:test:create", password, file_path1).unwrap();
let original_did = passport.did().as_str().to_string();
// Now import from the recovery phrase
@ -59,6 +62,7 @@ mod tests {
let file_path2 = temp_file2.path().to_str().unwrap();
let imported_passport = import_use_case.execute(
"univ:test:create",
recovery_phrase.words(),
password,
file_path2,
@ -83,7 +87,7 @@ mod tests {
let file_path1 = temp_file1.path().to_str().unwrap();
let password = "test-password";
let (passport, _) = create_use_case.execute(password, file_path1).unwrap();
let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap();
let original_did = passport.did().as_str().to_string();
// Now import from the file
@ -117,7 +121,7 @@ mod tests {
let file_path1 = temp_file1.path().to_str().unwrap();
let password = "test-password";
let (passport, _) = create_use_case.execute(password, file_path1).unwrap();
let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap();
let original_did = passport.did().as_str().to_string();
// Now import and re-encrypt to a new file
@ -157,7 +161,7 @@ mod tests {
let file_path1 = temp_file1.path().to_str().unwrap();
let password = "test-password";
let (passport, _) = create_use_case.execute(password, file_path1).unwrap();
let (passport, _) = create_use_case.execute("univ:test:create", password, file_path1).unwrap();
let original_did = passport.did().as_str().to_string();
// Now export with a new password
@ -204,7 +208,7 @@ mod tests {
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
create_use_case.execute(password, file_path).unwrap();
create_use_case.execute("univ:test:create", password, file_path).unwrap();
// Try to import with wrong password
let import_use_case = ImportFromFileUseCase::new(
@ -237,7 +241,7 @@ mod tests {
let invalid_words = vec!["invalid".to_string(); 24];
let result = use_case.execute(&invalid_words, password, file_path);
let result = use_case.execute("univ:test:create", &invalid_words, password, file_path);
// Should fail due to invalid mnemonic
assert!(result.is_err());
@ -259,7 +263,7 @@ mod tests {
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (passport, _) = create_use_case.execute(password, file_path).unwrap();
let (passport, _) = create_use_case.execute("univ:test:create", password, file_path).unwrap();
// Sign a test message
let test_message = "Hello, Sharenet!";
@ -273,8 +277,10 @@ mod tests {
&passport.public_key().0[..].try_into().unwrap()
).unwrap();
// Create the universe-bound message that was actually signed
let universe_bound_message = format!("u:{}:{}", passport.univ_id(), test_message);
let signature_obj = ed25519_dalek::Signature::from_bytes(&signature.try_into().unwrap());
let verification_result = verifying_key.verify_strict(test_message.as_bytes(), &signature_obj);
let verification_result = verifying_key.verify_strict(universe_bound_message.as_bytes(), &signature_obj);
assert!(verification_result.is_ok());
}
@ -295,7 +301,7 @@ mod tests {
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (passport, _) = create_use_case.execute(password, file_path).unwrap();
let (passport, _) = create_use_case.execute("univ:test:create", password, file_path).unwrap();
// Sign two different messages
let message1 = "Message 1";
@ -311,4 +317,607 @@ mod tests {
assert_eq!(signature1.len(), 64);
assert_eq!(signature2.len(), 64);
}
#[test]
fn test_user_profile_management_integration() {
use crate::application::use_cases::{CreateUserProfileUseCase, UpdateUserProfileUseCase, DeleteUserProfileUseCase};
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:profiles", password, file_path).unwrap();
// Verify default profile exists
assert!(passport.default_user_profile().is_some());
assert_eq!(passport.user_profiles().len(), 1);
// Create user profile use cases
let create_profile_use_case = CreateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let update_profile_use_case = UpdateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let delete_profile_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Create a hub-specific profile
let hub_identity = UserIdentity {
handle: Some("hubuser".to_string()),
display_name: Some("Hub User".to_string()),
first_name: Some("Hub".to_string()),
last_name: Some("User".to_string()),
email: Some("hub@example.com".to_string()),
avatar_url: Some("https://example.com/avatar.png".to_string()),
bio: Some("Hub user bio".to_string()),
};
let hub_preferences = UserPreferences {
theme: Some("light".to_string()),
language: Some("fr".to_string()),
notifications_enabled: false,
auto_sync: true,
};
create_profile_use_case.execute(
&mut passport,
Some("h:hub1".to_string()),
hub_identity,
hub_preferences,
password,
file_path,
).unwrap();
// Verify profile was added
assert_eq!(passport.user_profiles().len(), 2);
let hub_profile = passport.user_profile_for_hub("h:hub1").unwrap();
assert_eq!(hub_profile.identity.handle, Some("hubuser".to_string()));
assert_eq!(hub_profile.identity.display_name, Some("Hub User".to_string()));
assert_eq!(hub_profile.preferences.language, Some("fr".to_string()));
// Update the hub profile
let updated_identity = UserIdentity {
handle: Some("updatedhubuser".to_string()),
display_name: Some("Updated Hub User".to_string()),
first_name: Some("Updated".to_string()),
last_name: Some("Hub User".to_string()),
email: Some("updated@example.com".to_string()),
avatar_url: Some("https://example.com/new-avatar.png".to_string()),
bio: Some("Updated bio".to_string()),
};
let updated_preferences = UserPreferences {
theme: Some("dark".to_string()),
language: Some("en".to_string()),
notifications_enabled: true,
auto_sync: false,
};
// Get the profile ID for the hub profile
let profile_id = passport.user_profile_for_hub("h:hub1").unwrap().id.clone();
update_profile_use_case.execute(
&mut passport,
Some(&profile_id),
updated_identity,
updated_preferences,
password,
file_path,
).unwrap();
// Verify profile was updated
let updated_profile = passport.user_profile_for_hub("h:hub1").unwrap();
assert_eq!(updated_profile.identity.handle, Some("updatedhubuser".to_string()));
assert_eq!(updated_profile.identity.display_name, Some("Updated Hub User".to_string()));
assert_eq!(updated_profile.preferences.theme, Some("dark".to_string()));
assert_eq!(updated_profile.preferences.language, Some("en".to_string()));
// Delete the hub profile
let profile_id = passport.user_profile_for_hub("h:hub1").unwrap().id.clone();
delete_profile_use_case.execute(
&mut passport,
Some(&profile_id),
password,
file_path,
).unwrap();
// Verify profile was removed
assert_eq!(passport.user_profiles().len(), 1);
assert!(passport.user_profile_for_hub("h:hub1").is_none());
// Verify default profile still exists
assert!(passport.default_user_profile().is_some());
}
#[test]
fn test_user_profile_persistence_across_import() {
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport with user profiles
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:persistence", password, file_path).unwrap();
// Add a hub profile
let create_profile_use_case = CreateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let hub_identity = UserIdentity {
handle: Some("persistentuser".to_string()),
display_name: Some("Persistent User".to_string()),
first_name: Some("Persistent".to_string()),
last_name: Some("User".to_string()),
email: Some("persistent@example.com".to_string()),
avatar_url: None,
bio: Some("This should persist".to_string()),
};
let hub_preferences = UserPreferences {
theme: Some("auto".to_string()),
language: Some("es".to_string()),
notifications_enabled: true,
auto_sync: true,
};
create_profile_use_case.execute(
&mut passport,
Some("h:persistence".to_string()),
hub_identity,
hub_preferences,
password,
file_path,
).unwrap();
// Now import from the file
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let imported_passport = import_use_case.execute(
file_path,
password,
None,
).unwrap();
// Verify user profiles persisted
assert_eq!(imported_passport.user_profiles().len(), 2);
// Verify default profile
assert!(imported_passport.default_user_profile().is_some());
// Verify hub profile
let imported_hub_profile = imported_passport.user_profile_for_hub("h:persistence").unwrap();
assert_eq!(imported_hub_profile.identity.handle, Some("persistentuser".to_string()));
assert_eq!(imported_hub_profile.identity.display_name, Some("Persistent User".to_string()));
assert_eq!(imported_hub_profile.identity.email, Some("persistent@example.com".to_string()));
assert_eq!(imported_hub_profile.preferences.language, Some("es".to_string()));
assert_eq!(imported_hub_profile.preferences.theme, Some("auto".to_string()));
}
#[test]
fn test_update_user_profile_use_case() {
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:update", password, file_path).unwrap();
// Add a hub profile to update
let create_profile_use_case = CreateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let original_identity = UserIdentity {
handle: Some("originaluser".to_string()),
display_name: Some("Original User".to_string()),
first_name: Some("Original".to_string()),
last_name: Some("User".to_string()),
email: Some("original@example.com".to_string()),
avatar_url: None,
bio: Some("Original bio".to_string()),
};
let original_preferences = UserPreferences {
theme: Some("light".to_string()),
language: Some("en".to_string()),
notifications_enabled: false,
auto_sync: false,
};
create_profile_use_case.execute(
&mut passport,
Some("h:update".to_string()),
original_identity,
original_preferences,
password,
file_path,
).unwrap();
// Get the profile ID
let profile_id = passport.user_profile_for_hub("h:update").unwrap().id.clone();
// Create update use case
let update_profile_use_case = UpdateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Update the profile
let updated_identity = UserIdentity {
handle: Some("updateduser".to_string()),
display_name: Some("Updated User".to_string()),
first_name: Some("Updated".to_string()),
last_name: Some("User".to_string()),
email: Some("updated@example.com".to_string()),
avatar_url: Some("https://example.com/new-avatar.png".to_string()),
bio: Some("Updated bio".to_string()),
};
let updated_preferences = UserPreferences {
theme: Some("dark".to_string()),
language: Some("fr".to_string()),
notifications_enabled: true,
auto_sync: true,
};
update_profile_use_case.execute(
&mut passport,
Some(&profile_id),
updated_identity,
updated_preferences,
password,
file_path,
).unwrap();
// Verify the profile was updated
let updated_profile = passport.user_profile_for_hub("h:update").unwrap();
assert_eq!(updated_profile.identity.handle, Some("updateduser".to_string()));
assert_eq!(updated_profile.identity.display_name, Some("Updated User".to_string()));
assert_eq!(updated_profile.identity.email, Some("updated@example.com".to_string()));
assert_eq!(updated_profile.preferences.theme, Some("dark".to_string()));
assert_eq!(updated_profile.preferences.language, Some("fr".to_string()));
assert!(updated_profile.preferences.notifications_enabled);
assert!(updated_profile.preferences.auto_sync);
// Verify the ID and hub_did remain the same
assert_eq!(updated_profile.id, profile_id);
assert_eq!(updated_profile.hub_did, Some("h:update".to_string()));
// Verify updated_at timestamp changed
assert!(updated_profile.updated_at > 0);
}
#[test]
fn test_update_user_profile_use_case_invalid_id() {
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:update_invalid", password, file_path).unwrap();
// Create update use case
let update_profile_use_case = UpdateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Try to update with invalid ID
let identity = UserIdentity {
handle: Some("test".to_string()),
display_name: Some("Test".to_string()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
email: Some("test@example.com".to_string()),
avatar_url: None,
bio: None,
};
let preferences = UserPreferences {
theme: Some("dark".to_string()),
language: Some("en".to_string()),
notifications_enabled: true,
auto_sync: false,
};
let result = update_profile_use_case.execute(
&mut passport,
Some("invalid-uuid"),
identity,
preferences,
password,
file_path,
);
// Should fail with profile not found
assert!(result.is_err());
}
#[test]
fn test_update_user_profile_use_case_missing_id() {
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:update_missing", password, file_path).unwrap();
// Create update use case
let update_profile_use_case = UpdateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Try to update with missing ID
let identity = UserIdentity {
handle: Some("test".to_string()),
display_name: Some("Test".to_string()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
email: Some("test@example.com".to_string()),
avatar_url: None,
bio: None,
};
let preferences = UserPreferences {
theme: Some("dark".to_string()),
language: Some("en".to_string()),
notifications_enabled: true,
auto_sync: false,
};
let result = update_profile_use_case.execute(
&mut passport,
None,
identity,
preferences,
password,
file_path,
);
// Should fail with ID required
assert!(result.is_err());
}
#[test]
fn test_delete_user_profile_use_case() {
use crate::domain::entities::{UserIdentity, UserPreferences};
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:delete", password, file_path).unwrap();
// Add a hub profile to delete
let create_profile_use_case = CreateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let identity = UserIdentity {
handle: Some("todelete".to_string()),
display_name: Some("To Delete".to_string()),
first_name: Some("To".to_string()),
last_name: Some("Delete".to_string()),
email: Some("delete@example.com".to_string()),
avatar_url: None,
bio: Some("This will be deleted".to_string()),
};
let preferences = UserPreferences {
theme: Some("light".to_string()),
language: Some("en".to_string()),
notifications_enabled: false,
auto_sync: false,
};
create_profile_use_case.execute(
&mut passport,
Some("h:delete".to_string()),
identity,
preferences,
password,
file_path,
).unwrap();
// Verify profile was added
assert_eq!(passport.user_profiles().len(), 2);
assert!(passport.user_profile_for_hub("h:delete").is_some());
// Get the profile ID
let profile_id = passport.user_profile_for_hub("h:delete").unwrap().id.clone();
// Create delete use case
let delete_profile_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Delete the profile
delete_profile_use_case.execute(
&mut passport,
Some(&profile_id),
password,
file_path,
).unwrap();
// Verify profile was deleted
assert_eq!(passport.user_profiles().len(), 1);
assert!(passport.user_profile_for_hub("h:delete").is_none());
// Verify default profile still exists
assert!(passport.default_user_profile().is_some());
}
#[test]
fn test_delete_user_profile_use_case_invalid_id() {
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:delete_invalid", password, file_path).unwrap();
// Create delete use case
let delete_profile_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Try to delete with invalid ID
let result = delete_profile_use_case.execute(
&mut passport,
Some("invalid-uuid"),
password,
file_path,
);
// Should fail with profile not found
assert!(result.is_err());
}
#[test]
fn test_delete_user_profile_use_case_missing_id() {
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:delete_missing", password, file_path).unwrap();
// Create delete use case
let delete_profile_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Try to delete with missing ID
let result = delete_profile_use_case.execute(
&mut passport,
None,
password,
file_path,
);
// Should fail with ID required
assert!(result.is_err());
}
#[test]
fn test_delete_user_profile_use_case_cannot_delete_default() {
// First create a passport
let create_use_case = CreatePassportUseCase::new(
Bip39MnemonicGenerator,
Ed25519KeyDeriver,
XChaCha20FileEncryptor,
FileSystemStorage,
);
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let password = "test-password";
let (mut passport, _) = create_use_case.execute("univ:test:delete_default", password, file_path).unwrap();
// Get the default profile ID
let default_profile_id = passport.default_user_profile().unwrap().id.clone();
// Create delete use case
let delete_profile_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
// Try to delete the default profile
let result = delete_profile_use_case.execute(
&mut passport,
Some(&default_profile_id),
password,
file_path,
);
// Should fail - cannot delete default profile
assert!(result.is_err());
}
}

View file

@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -43,8 +45,8 @@ pub struct Did(pub String);
impl Did {
pub fn new(public_key: &PublicKey) -> Self {
// Simple DID format for now - in production this would use proper DID method
let did_str = format!("did:sharenet:{}", hex::encode(&public_key.0));
// Passport DID format with "p:" prefix
let did_str = format!("p:{}", hex::encode(&public_key.0));
Self(did_str)
}
@ -74,6 +76,8 @@ pub struct Passport {
pub public_key: PublicKey,
pub private_key: PrivateKey,
pub did: Did,
pub univ_id: String,
pub user_profiles: Vec<UserProfile>,
}
impl Passport {
@ -81,13 +85,37 @@ impl Passport {
seed: Seed,
public_key: PublicKey,
private_key: PrivateKey,
univ_id: String,
) -> Self {
let did = Did::new(&public_key);
// Create default user profile
let default_profile = UserProfile::new(
None,
UserIdentity {
handle: None,
display_name: None,
first_name: None,
last_name: None,
email: None,
avatar_url: None,
bio: None,
},
UserPreferences {
theme: None,
language: None,
notifications_enabled: true,
auto_sync: true,
},
);
Self {
seed,
public_key,
private_key,
did,
univ_id,
user_profiles: vec![default_profile],
}
}
@ -98,6 +126,164 @@ impl Passport {
pub fn did(&self) -> &Did {
&self.did
}
pub fn univ_id(&self) -> &str {
&self.univ_id
}
pub fn user_profiles(&self) -> &[UserProfile] {
&self.user_profiles
}
pub fn default_user_profile(&self) -> Option<&UserProfile> {
self.user_profiles.iter().find(|p| p.is_default())
}
pub fn user_profile_for_hub(&self, hub_did: &str) -> Option<&UserProfile> {
self.user_profiles.iter().find(|p| p.hub_did.as_deref() == Some(hub_did))
}
pub fn user_profile_by_id(&self, profile_id: &str) -> Option<&UserProfile> {
self.user_profiles.iter().find(|p| p.id == profile_id)
}
pub fn user_profile_by_id_mut(&mut self, profile_id: &str) -> Option<&mut UserProfile> {
self.user_profiles.iter_mut().find(|p| p.id == profile_id)
}
pub fn add_user_profile(&mut self, profile: UserProfile) -> Result<(), String> {
// Ensure only one default profile
if profile.is_default() && self.default_user_profile().is_some() {
return Err("Default user profile already exists".to_string());
}
// Ensure hub_did is unique
if let Some(hub_did) = &profile.hub_did {
if self.user_profile_for_hub(hub_did).is_some() {
return Err(format!("User profile for hub DID {} already exists", hub_did));
}
}
self.user_profiles.push(profile);
Ok(())
}
pub fn update_user_profile(&mut self, hub_did: Option<&str>, profile: UserProfile) -> Result<(), String> {
let index = self.user_profiles.iter().position(|p| {
match (p.hub_did.as_deref(), hub_did) {
(None, None) => true, // Default profile
(Some(p_hub), Some(hub)) if p_hub == hub => true, // Hub-specific profile
_ => false,
}
});
match index {
Some(idx) => {
self.user_profiles[idx] = profile;
Ok(())
}
None => Err("User profile not found".to_string()),
}
}
pub fn remove_user_profile(&mut self, hub_did: Option<&str>) -> Result<(), String> {
if hub_did.is_none() {
return Err("Cannot delete default user profile".to_string());
}
let index = self.user_profiles.iter().position(|p| p.hub_did.as_deref() == hub_did);
match index {
Some(idx) => {
self.user_profiles.remove(idx);
Ok(())
}
None => Err("User profile not found".to_string()),
}
}
pub fn update_user_profile_by_id(&mut self, profile_id: &str, profile: UserProfile) -> Result<(), String> {
let index = self.user_profiles.iter().position(|p| p.id == profile_id);
match index {
Some(idx) => {
self.user_profiles[idx] = profile;
Ok(())
}
None => Err("User profile not found".to_string()),
}
}
pub fn remove_user_profile_by_id(&mut self, profile_id: &str) -> Result<(), String> {
let index = self.user_profiles.iter().position(|p| p.id == profile_id);
match index {
Some(idx) => {
// Check if this is the default profile
if self.user_profiles[idx].is_default() {
return Err("Cannot delete default user profile".to_string());
}
self.user_profiles.remove(idx);
Ok(())
}
None => Err("User profile not found".to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserIdentity {
pub handle: Option<String>,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPreferences {
pub theme: Option<String>,
pub language: Option<String>,
pub notifications_enabled: bool,
pub auto_sync: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub id: String, // UUIDv7 unique identifier for the profile
pub hub_did: Option<String>, // None for default profile
pub identity: UserIdentity,
pub preferences: UserPreferences,
pub created_at: u64,
pub updated_at: u64,
}
impl UserProfile {
pub fn new(
hub_did: Option<String>,
identity: UserIdentity,
preferences: UserPreferences,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
id: Uuid::now_v7().to_string(),
hub_did,
identity,
preferences,
created_at: now,
updated_at: now,
}
}
pub fn is_default(&self) -> bool {
self.hub_did.is_none()
}
}
#[derive(Debug, Serialize, Deserialize)]
@ -109,6 +295,8 @@ pub struct PassportFile {
pub nonce: Vec<u8>,
pub public_key: Vec<u8>,
pub did: String,
pub univ_id: String,
pub created_at: u64,
pub version: String,
pub enc_user_profiles: Vec<u8>, // Encrypted CBOR of Vec<UserProfile>
}

View file

@ -21,8 +21,13 @@ mod tests {
let public_key = PublicKey(vec![1, 2, 3, 4, 5]);
let did = Did::new(&public_key);
assert!(did.as_str().starts_with("did:sharenet:"));
assert!(did.as_str().contains(&hex::encode(&public_key.0)));
// DID should have "p:" prefix followed by hex-encoded public key
let expected_did = format!("p:{}", hex::encode(&public_key.0));
assert_eq!(did.as_str(), expected_did);
// For a 5-byte public key, hex encoding should be 10 characters + "p:" prefix = 12 characters
assert_eq!(did.as_str().len(), 12);
assert!(did.as_str().starts_with("p:"));
assert!(did.as_str().chars().skip(2).all(|c| c.is_ascii_hexdigit()));
}
#[test]
@ -50,4 +55,194 @@ mod tests {
// After zeroization, bytes should be empty (zeroize clears the vector)
assert_eq!(private_key.0, vec![]);
}
#[test]
fn test_user_profile_creation() {
use crate::domain::entities::{UserIdentity, UserPreferences, UserProfile};
let identity = UserIdentity {
handle: Some("testuser".to_string()),
display_name: Some("Test User".to_string()),
first_name: Some("Test".to_string()),
last_name: Some("User".to_string()),
email: Some("test@example.com".to_string()),
avatar_url: Some("https://example.com/avatar.png".to_string()),
bio: Some("Test bio".to_string()),
};
let preferences = UserPreferences {
theme: Some("dark".to_string()),
language: Some("en".to_string()),
notifications_enabled: true,
auto_sync: false,
};
let profile = UserProfile {
id: "test-uuid-1234".to_string(),
hub_did: Some("h:example".to_string()),
identity,
preferences,
created_at: 1234567890,
updated_at: 1234567890,
};
assert_eq!(profile.hub_did, Some("h:example".to_string()));
assert_eq!(profile.identity.handle, Some("testuser".to_string()));
assert_eq!(profile.identity.display_name, Some("Test User".to_string()));
assert_eq!(profile.identity.first_name, Some("Test".to_string()));
assert_eq!(profile.identity.last_name, Some("User".to_string()));
assert_eq!(profile.identity.email, Some("test@example.com".to_string()));
assert_eq!(profile.preferences.theme, Some("dark".to_string()));
assert_eq!(profile.preferences.language, Some("en".to_string()));
assert!(profile.preferences.notifications_enabled);
assert!(!profile.preferences.auto_sync);
assert!(!profile.is_default());
}
#[test]
fn test_default_user_profile() {
use crate::domain::entities::{UserIdentity, UserPreferences, UserProfile};
let profile = UserProfile {
id: "test-uuid-default".to_string(),
hub_did: None,
identity: UserIdentity {
handle: None,
display_name: None,
first_name: None,
last_name: None,
email: None,
avatar_url: None,
bio: None,
},
preferences: UserPreferences {
theme: None,
language: None,
notifications_enabled: true,
auto_sync: true,
},
created_at: 1234567890,
updated_at: 1234567890,
};
assert!(profile.is_default());
assert_eq!(profile.hub_did, None);
}
#[test]
fn test_passport_user_profile_management() {
use crate::domain::entities::{Passport, UserIdentity, UserPreferences, UserProfile, Seed, PublicKey, PrivateKey};
let seed = Seed::new(vec![1, 2, 3, 4, 5]);
let public_key = PublicKey(vec![1, 2, 3]);
let private_key = PrivateKey(vec![4, 5, 6]);
let univ_id = "test-universe".to_string();
let mut passport = Passport::new(seed, public_key, private_key, univ_id);
// Test default profile exists
assert!(passport.default_user_profile().is_some());
assert_eq!(passport.user_profiles().len(), 1);
// Test adding a hub-specific profile
let hub_profile = UserProfile {
id: "test-uuid-hub".to_string(),
hub_did: Some("h:example".to_string()),
identity: UserIdentity {
handle: Some("hubuser".to_string()),
display_name: Some("Hub User".to_string()),
first_name: Some("Hub".to_string()),
last_name: Some("User".to_string()),
email: None,
avatar_url: None,
bio: None,
},
preferences: UserPreferences {
theme: Some("light".to_string()),
language: None,
notifications_enabled: false,
auto_sync: true,
},
created_at: 1234567890,
updated_at: 1234567890,
};
let result = passport.add_user_profile(hub_profile);
assert!(result.is_ok());
assert_eq!(passport.user_profiles().len(), 2);
// Test finding profile by hub DID
let found_profile = passport.user_profile_for_hub("h:example");
assert!(found_profile.is_some());
assert_eq!(found_profile.unwrap().identity.handle, Some("hubuser".to_string()));
assert_eq!(found_profile.unwrap().identity.display_name, Some("Hub User".to_string()));
// Test duplicate hub DID rejection
let duplicate_profile = UserProfile {
id: "test-uuid-duplicate".to_string(),
hub_did: Some("h:example".to_string()),
identity: UserIdentity {
handle: Some("anotheruser".to_string()),
display_name: Some("Another User".to_string()),
first_name: Some("Another".to_string()),
last_name: Some("User".to_string()),
email: None,
avatar_url: None,
bio: None,
},
preferences: UserPreferences {
theme: None,
language: None,
notifications_enabled: true,
auto_sync: false,
},
created_at: 1234567890,
updated_at: 1234567890,
};
let result = passport.add_user_profile(duplicate_profile);
assert!(result.is_err());
// Test updating profile
let hub_profile_id = passport.user_profile_for_hub("h:example").unwrap().id.clone();
let updated_profile = UserProfile {
id: hub_profile_id.clone(), // Same ID as original
hub_did: Some("h:example".to_string()),
identity: UserIdentity {
handle: Some("updateduser".to_string()),
display_name: Some("Updated User".to_string()),
first_name: Some("Updated".to_string()),
last_name: Some("User".to_string()),
email: None,
avatar_url: None,
bio: None,
},
preferences: UserPreferences {
theme: Some("dark".to_string()),
language: None,
notifications_enabled: true,
auto_sync: false,
},
created_at: 1234567890,
updated_at: 1234567890,
};
let result = passport.update_user_profile_by_id(&hub_profile_id, updated_profile);
assert!(result.is_ok());
let found_profile = passport.user_profile_for_hub("h:example");
assert_eq!(found_profile.unwrap().identity.handle, Some("updateduser".to_string()));
assert_eq!(found_profile.unwrap().identity.display_name, Some("Updated User".to_string()));
// Test removing profile
let result = passport.remove_user_profile_by_id(&hub_profile_id);
assert!(result.is_ok());
assert_eq!(passport.user_profiles().len(), 1);
// Test cannot remove default profile
let default_profile_id = passport.default_user_profile().unwrap().id.clone();
let result = passport.remove_user_profile_by_id(&default_profile_id);
assert!(result.is_err());
}
}

View file

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

View file

@ -53,13 +53,13 @@ impl KeyDeriver for Ed25519KeyDeriver {
))
}
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase) -> Result<Seed, Self::Error> {
fn derive_from_mnemonic(&self, mnemonic: &RecoveryPhrase, univ_id: &str) -> Result<Seed, Self::Error> {
let phrase = mnemonic.words().join(" ");
let bip39_mnemonic = Mnemonic::parse(&phrase)
.map_err(|e| DomainError::InvalidMnemonic(format!("Invalid mnemonic: {}", e)))?;
// Use empty passphrase for now
let bip39_seed = bip39_mnemonic.to_seed("");
// Use univ_id as passphrase to bind seed to universe
let bip39_seed = bip39_mnemonic.to_seed(univ_id);
Ok(Seed::new(bip39_seed.to_vec()))
}
}
@ -76,6 +76,8 @@ impl FileEncryptor for XChaCha20FileEncryptor {
password: &str,
public_key: &PublicKey,
did: &Did,
univ_id: &str,
user_profiles: &[UserProfile],
) -> Result<PassportFile, Self::Error> {
// Generate salt and nonce
let mut salt = [0u8; 32];
@ -96,6 +98,14 @@ impl FileEncryptor for XChaCha20FileEncryptor {
.encrypt(nonce, seed.as_bytes())
.map_err(|e| DomainError::CryptographicError(format!("Encryption failed: {}", e)))?;
// Serialize and encrypt user profiles
let user_profiles_vec: Vec<UserProfile> = user_profiles.to_vec();
let user_profiles_bytes = serde_cbor::to_vec(&user_profiles_vec)
.map_err(|e| DomainError::CryptographicError(format!("Failed to serialize user profiles: {}", e)))?;
let enc_user_profiles = cipher
.encrypt(nonce, &*user_profiles_bytes)
.map_err(|e| DomainError::CryptographicError(format!("User profiles encryption failed: {}", e)))?;
// Get current timestamp
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -110,8 +120,10 @@ impl FileEncryptor for XChaCha20FileEncryptor {
nonce: nonce_bytes.to_vec(),
public_key: public_key.0.clone(),
did: did.0.clone(),
univ_id: univ_id.to_string(),
created_at,
version: "1.0.0".to_string(),
enc_user_profiles,
})
}
@ -119,7 +131,7 @@ impl FileEncryptor for XChaCha20FileEncryptor {
&self,
file: &PassportFile,
password: &str,
) -> Result<(Seed, PublicKey, PrivateKey), Self::Error> {
) -> Result<(Seed, PublicKey, PrivateKey, Vec<UserProfile>), Self::Error> {
// Validate file format
if file.kdf != "HKDF-SHA256" || file.cipher != "XChaCha20-Poly1305" {
return Err(DomainError::InvalidFileFormat(
@ -153,6 +165,14 @@ impl FileEncryptor for XChaCha20FileEncryptor {
));
}
Ok((seed, public_key, private_key))
// Decrypt user profiles
let user_profiles_bytes = cipher
.decrypt(nonce, &*file.enc_user_profiles)
.map_err(|e| DomainError::CryptographicError(format!("User profiles decryption failed: {}", e)))?;
let user_profiles: Vec<UserProfile> = serde_cbor::from_slice(&user_profiles_bytes)
.map_err(|e| DomainError::CryptographicError(format!("Failed to deserialize user profiles: {}", e)))?;
// Note: univ_id is stored in the PassportFile and will be used when creating the Passport
Ok((seed, public_key, private_key, user_profiles))
}
}

View file

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

View file

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

View file

@ -0,0 +1,232 @@
use crate::application::use_cases::*;
use crate::domain::entities::*;
use crate::infrastructure::crypto::*;
use crate::infrastructure::storage::*;
use std::fs;
#[cfg(test)]
mod universe_binding_tests {
use super::*;
#[test]
fn test_passport_creation_with_different_universes() {
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator.clone(),
key_deriver.clone(),
file_encryptor.clone(),
file_storage.clone(),
);
// Create passports for different universes with the same mnemonic
let univ1 = "univ:test:alpha";
let univ2 = "univ:test:beta";
let password = "test_password";
// Create first passport
let (passport1, recovery_phrase) = create_use_case
.execute(univ1, password, "/tmp/test_passport1.spf")
.expect("Failed to create passport 1");
// Create second passport with same mnemonic but different universe
let import_use_case = ImportFromRecoveryUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor,
file_storage,
);
let passport2 = import_use_case
.execute(
univ2,
&recovery_phrase.words(),
password,
"/tmp/test_passport2.spf",
)
.expect("Failed to create passport 2");
// Verify universe binding
assert_eq!(passport1.univ_id(), univ1);
assert_eq!(passport2.univ_id(), univ2);
// Verify DIDs are universe-bound
assert!(passport1.did().as_str().contains(univ1));
assert!(passport2.did().as_str().contains(univ2));
assert_ne!(passport1.did().as_str(), passport2.did().as_str());
// Verify public keys are different (due to universe binding)
assert_ne!(
hex::encode(&passport1.public_key().0),
hex::encode(&passport2.public_key().0)
);
// Clean up
let _ = fs::remove_file("/tmp/test_passport1.spf");
let _ = fs::remove_file("/tmp/test_passport2.spf");
}
#[test]
fn test_universe_bound_card_signing() {
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor,
file_storage,
);
let univ_id = "univ:test:signing";
let password = "test_password";
let (passport, _) = create_use_case
.execute(univ_id, password, "/tmp/test_signing.spf")
.expect("Failed to create passport");
let sign_use_case = SignCardUseCase::new();
let message = "Hello, universe!";
let signature = sign_use_case
.execute(&passport, message)
.expect("Failed to sign message");
// Verify signature is universe-bound
let signing_key = ed25519_dalek::SigningKey::from_bytes(
&passport.private_key.0[..32].try_into().unwrap()
);
let verifying_key = signing_key.verifying_key();
// Correct universe-bound message should verify
let correct_message = format!("univ:{}:{}", univ_id, message);
assert!(verifying_key
.verify_strict(correct_message.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature).unwrap())
.is_ok());
// Wrong universe message should NOT verify
let wrong_message = format!("univ:{}:{}", "univ:wrong:universe", message);
assert!(verifying_key
.verify_strict(wrong_message.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature).unwrap())
.is_err());
// Clean up
let _ = fs::remove_file("/tmp/test_signing.spf");
}
#[test]
fn test_passport_file_stores_univ_id() {
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor.clone(),
file_storage.clone(),
);
let univ_id = "univ:test:storage";
let password = "test_password";
let (passport, _) = create_use_case
.execute(univ_id, password, "/tmp/test_storage.spf")
.expect("Failed to create passport");
// Load the file and verify univ_id is stored
let loaded_file = file_storage
.load("/tmp/test_storage.spf")
.expect("Failed to load passport file");
assert_eq!(loaded_file.univ_id, univ_id);
// Import from file and verify univ_id is preserved
let import_use_case = ImportFromFileUseCase::new(file_encryptor, file_storage);
let imported_passport = import_use_case
.execute("/tmp/test_storage.spf", password, None)
.expect("Failed to import passport");
assert_eq!(imported_passport.univ_id(), univ_id);
assert_eq!(imported_passport.did().as_str(), passport.did().as_str());
// Clean up
let _ = fs::remove_file("/tmp/test_storage.spf");
}
#[test]
fn test_cross_universe_prevention() {
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator.clone(),
key_deriver.clone(),
file_encryptor.clone(),
file_storage.clone(),
);
let univ1 = "univ:test:security1";
let univ2 = "univ:test:security2";
let password = "test_password";
// Create passport for universe 1
let (passport1, recovery_phrase) = create_use_case
.execute(univ1, password, "/tmp/test_security1.spf")
.expect("Failed to create passport 1");
// Try to import same mnemonic into universe 2
let import_use_case = ImportFromRecoveryUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor,
file_storage,
);
let passport2 = import_use_case
.execute(
univ2,
&recovery_phrase.words(),
password,
"/tmp/test_security2.spf",
)
.expect("Failed to create passport 2");
// Verify they are completely different identities
assert_ne!(passport1.univ_id(), passport2.univ_id());
assert_ne!(passport1.did().as_str(), passport2.did().as_str());
assert_ne!(
hex::encode(&passport1.public_key().0),
hex::encode(&passport2.public_key().0)
);
// Cards signed by passport1 should not be verifiable by passport2 and vice versa
let sign_use_case = SignCardUseCase::new();
let message = "Cross-universe test";
let signature1 = sign_use_case
.execute(&passport1, message)
.expect("Failed to sign with passport1");
// Verify signature1 cannot be verified with passport2's public key
let signing_key2 = ed25519_dalek::SigningKey::from_bytes(
&passport2.private_key.0[..32].try_into().unwrap()
);
let verifying_key2 = signing_key2.verifying_key();
let message_for_univ1 = format!("univ:{}:{}", univ1, message);
assert!(verifying_key2
.verify_strict(message_for_univ1.as_bytes(), &ed25519_dalek::Signature::from_bytes(&signature1).unwrap())
.is_err());
// Clean up
let _ = fs::remove_file("/tmp/test_security1.spf");
let _ = fs::remove_file("/tmp/test_security2.spf");
}
}

View file

@ -10,6 +10,7 @@ sharenet-passport = { path = "../libs/sharenet-passport" }
clap = { version = "4.4", features = ["derive"] }
rpassword = "7.2"
hex = "0.4"
uuid = { version = "1.7", features = ["v7"] }
[dev-dependencies]
assert_matches = "1.5"

View file

@ -0,0 +1,105 @@
use sharenet_passport::application::use_cases::*;
use sharenet_passport::domain::traits::*;
use sharenet_passport::infrastructure::crypto::*;
use sharenet_passport::infrastructure::storage::*;
use std::fs;
fn main() {
println!("Testing universe binding implementation...");
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator.clone(),
key_deriver.clone(),
file_encryptor.clone(),
file_storage.clone(),
);
// Test 1: Create passports for different universes
println!("\nTest 1: Creating passports for different universes...");
let univ1 = "univ:test:alpha";
let univ2 = "univ:test:beta";
let password = "test_password";
let (passport1, recovery_phrase) = create_use_case
.execute(univ1, password, "/tmp/test_passport1.spf")
.expect("Failed to create passport 1");
println!("✓ Passport 1 created for universe: {}", passport1.univ_id());
println!(" DID: {}", passport1.did().as_str());
let import_use_case = ImportFromRecoveryUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor,
file_storage,
);
let passport2 = import_use_case
.execute(
univ2,
&recovery_phrase.words(),
password,
"/tmp/test_passport2.spf",
)
.expect("Failed to create passport 2");
println!("✓ Passport 2 created for universe: {}", passport2.univ_id());
println!(" DID: {}", passport2.did().as_str());
// Verify universe binding
assert_eq!(passport1.univ_id(), univ1);
assert_eq!(passport2.univ_id(), univ2);
assert_ne!(passport1.did().as_str(), passport2.did().as_str());
assert_ne!(
hex::encode(&passport1.public_key().0),
hex::encode(&passport2.public_key().0)
);
println!("✓ Universe binding verified - different universes produce different identities");
// Test 2: Universe-bound card signing
println!("\nTest 2: Testing universe-bound card signing...");
let sign_use_case = SignCardUseCase::new();
let message = "Hello, universe!";
let _signature = sign_use_case
.execute(&passport1, message)
.expect("Failed to sign message");
println!("✓ Message signed successfully");
// Test 3: Verify PassportFile stores univ_id
println!("\nTest 3: Verifying PassportFile stores univ_id...");
let loaded_file = FileSystemStorage
.load("/tmp/test_passport1.spf")
.expect("Failed to load passport file");
assert_eq!(loaded_file.univ_id, univ1);
println!("✓ PassportFile correctly stores univ_id: {}", loaded_file.univ_id);
// Test 4: Import from file preserves univ_id
println!("\nTest 4: Testing import from file preserves univ_id...");
let import_file_use_case = ImportFromFileUseCase::new(XChaCha20FileEncryptor, FileSystemStorage);
let imported_passport = import_file_use_case
.execute("/tmp/test_passport1.spf", password, None)
.expect("Failed to import passport");
assert_eq!(imported_passport.univ_id(), univ1);
println!("✓ Imported passport preserves univ_id: {}", imported_passport.univ_id());
// Clean up
let _ = fs::remove_file("/tmp/test_passport1.spf");
let _ = fs::remove_file("/tmp/test_passport2.spf");
println!("\n🎉 All universe binding tests passed successfully!");
println!("\nSummary:");
println!("- Passports are cryptographically bound to their universe");
println!("- Same mnemonic + different universe = different identities");
println!("- DIDs include universe identifier");
println!("- Card signatures are universe-bound");
println!("- Passport files store univ_id for verification");
}

View file

@ -1,7 +1,7 @@
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "sharenet-passport")]
#[command(name = "sharenet-passport-cli")]
#[command(about = "Sharenet Passport Creator - Generate and manage cryptographic identities")]
#[command(version)]
pub struct Cli {
@ -13,13 +13,27 @@ pub struct Cli {
pub enum Commands {
/// Create a new Passport
Create {
/// Universe identifier (e.g., "u:My Universe:uuid")
#[arg(short, long)]
universe: String,
/// Output file path for the .spf file
#[arg(short, long, default_value = "passport.spf")]
output: String,
},
/// Create a new universe identifier
CreateUniverse {
/// Universe name
name: String,
},
/// Import a Passport from recovery phrase
ImportRecovery {
/// Universe identifier (e.g., "u:My Universe:uuid")
#[arg(short, long)]
universe: String,
/// Output file path for the .spf file
#[arg(short, long, default_value = "passport.spf")]
output: String,
@ -59,4 +73,141 @@ pub enum Commands {
/// Message to sign
message: String,
},
/// User Profile Management
Profile {
#[command(subcommand)]
command: ProfileCommands,
},
}
#[derive(Subcommand)]
pub enum ProfileCommands {
/// List all user profiles
List {
/// .spf file path
file: String,
},
/// Create a new user profile
Create {
/// .spf file path
file: String,
/// Hub DID (optional, omit for default profile)
#[arg(short, long)]
hub_did: Option<String>,
/// Handle
#[arg(long)]
handle: Option<String>,
/// Display name
#[arg(short, long)]
display_name: Option<String>,
/// First name
#[arg(long)]
first_name: Option<String>,
/// Last name
#[arg(long)]
last_name: Option<String>,
/// Email
#[arg(short, long)]
email: Option<String>,
/// Avatar URL
#[arg(short, long)]
avatar_url: Option<String>,
/// Bio
#[arg(short, long)]
bio: Option<String>,
/// Theme preference
#[arg(long)]
theme: Option<String>,
/// Language preference
#[arg(long)]
language: Option<String>,
/// Enable notifications
#[arg(long)]
notifications: bool,
/// Enable auto-sync
#[arg(long)]
auto_sync: bool,
},
/// Update an existing user profile
Update {
/// .spf file path
file: String,
/// Profile ID (required, use 'list' command to see available IDs)
#[arg(short, long)]
id: String,
/// Hub DID (optional, can be updated)
#[arg(long)]
hub_did: Option<String>,
/// Handle
#[arg(long)]
handle: Option<String>,
/// Display name
#[arg(short, long)]
display_name: Option<String>,
/// First name
#[arg(long)]
first_name: Option<String>,
/// Last name
#[arg(long)]
last_name: Option<String>,
/// Email
#[arg(short, long)]
email: Option<String>,
/// Avatar URL
#[arg(short, long)]
avatar_url: Option<String>,
/// Bio
#[arg(short, long)]
bio: Option<String>,
/// Theme preference
#[arg(long)]
theme: Option<String>,
/// Language preference
#[arg(long)]
language: Option<String>,
/// Enable notifications
#[arg(long)]
notifications: Option<bool>,
/// Enable auto-sync
#[arg(long)]
auto_sync: Option<bool>,
},
/// Delete a user profile
Delete {
/// .spf file path
file: String,
/// Profile ID (required, use 'list' command to see available IDs)
#[arg(short, long)]
id: String,
},
}

View file

@ -1,11 +1,13 @@
use sharenet_passport::{
application::use_cases::*,
domain::entities::{UserIdentity, UserPreferences},
infrastructure::*,
ApplicationError,
FileStorage,
};
use rpassword::prompt_password;
use hex;
use uuid::Uuid;
pub struct CliInterface;
@ -14,9 +16,24 @@ impl CliInterface {
Self
}
pub fn handle_create(&self, output: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for new passport: ").unwrap();
let confirm_password = prompt_password("Confirm password: ").unwrap();
pub fn handle_create_universe(&self, name: &str) -> Result<(), ApplicationError> {
let uuid = Uuid::now_v7();
let universe_id = format!("u:{}:{}", name, uuid);
println!("🌌 Universe created successfully!");
println!("📝 Universe Name: {}", name);
println!("🆔 Universe ID: {}", universe_id);
println!("\n💡 Use this Universe ID when creating Passports:");
println!(" sharenet-passport create --universe '{}'", universe_id);
Ok(())
}
pub fn handle_create(&self, universe: &str, output: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for new passport: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let confirm_password = prompt_password("Confirm password: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
if password != confirm_password {
return Err(ApplicationError::UseCaseError("Passwords do not match".to_string()));
@ -29,7 +46,7 @@ impl CliInterface {
FileSystemStorage,
);
let (passport, recovery_phrase) = use_case.execute(&password, output)?;
let (passport, recovery_phrase) = use_case.execute(universe, &password, output)?;
println!("✅ Passport created successfully!");
println!("📄 Saved to: {}", output);
@ -41,17 +58,20 @@ impl CliInterface {
Ok(())
}
pub fn handle_import_recovery(&self, output: &str) -> Result<(), ApplicationError> {
pub fn handle_import_recovery(&self, universe: &str, output: &str) -> Result<(), ApplicationError> {
println!("Enter your 24-word recovery phrase:");
let mut recovery_words = Vec::new();
for i in 1..=24 {
let word = prompt_password(&format!("Word {}: ", i)).unwrap();
let word = prompt_password(&format!("Word {}: ", i))
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read recovery word: {}", e)))?;
recovery_words.push(word);
}
let password = prompt_password("Enter new password for passport file: ").unwrap();
let confirm_password = prompt_password("Confirm password: ").unwrap();
let password = prompt_password("Enter new password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let confirm_password = prompt_password("Confirm password: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
if password != confirm_password {
return Err(ApplicationError::UseCaseError("Passwords do not match".to_string()));
@ -64,7 +84,7 @@ impl CliInterface {
FileSystemStorage,
);
let passport = use_case.execute(&recovery_words, &password, output)?;
let passport = use_case.execute(universe, &recovery_words, &password, output)?;
println!("✅ Passport imported successfully!");
println!("📄 Saved to: {}", output);
@ -75,7 +95,8 @@ impl CliInterface {
}
pub fn handle_import_file(&self, input: &str, output: Option<&str>) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ").unwrap();
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
@ -95,9 +116,12 @@ impl CliInterface {
}
pub fn handle_export(&self, input: &str, output: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ").unwrap();
let new_password = prompt_password("Enter new password for exported file: ").unwrap();
let confirm_password = prompt_password("Confirm new password: ").unwrap();
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let new_password = prompt_password("Enter new password for exported file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let confirm_password = prompt_password("Confirm new password: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
if new_password != confirm_password {
return Err(ApplicationError::UseCaseError("Passwords do not match".to_string()));
@ -131,6 +155,7 @@ impl CliInterface {
println!("📄 Passport File Information:");
println!(" File: {}", file);
println!(" Universe ID: {}", passport_file.univ_id);
println!(" Version: {}", passport_file.version);
println!(" Created: {}", passport_file.created_at);
println!(" DID: {}", passport_file.did);
@ -142,7 +167,8 @@ impl CliInterface {
}
pub fn handle_sign(&self, file: &str, message: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ").unwrap();
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
@ -160,4 +186,252 @@ impl CliInterface {
Ok(())
}
pub fn handle_profile_list(&self, file: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let passport = import_use_case.execute(file, &password, None)?;
println!("👤 User Profiles:");
for (i, profile) in passport.user_profiles().iter().enumerate() {
println!("\n{}. Profile ID: {}", i + 1, profile.id);
println!(" Profile Type: {}", if profile.is_default() { "Default" } else { "Hub-specific" });
if let Some(hub_did) = &profile.hub_did {
println!(" Hub DID: {}", hub_did);
}
println!(" Created: {}", profile.created_at);
println!(" Updated: {}", profile.updated_at);
println!(" Identity:");
if let Some(handle) = &profile.identity.handle {
println!(" Handle: {}", handle);
}
if let Some(name) = &profile.identity.display_name {
println!(" Display Name: {}", name);
}
if let Some(first_name) = &profile.identity.first_name {
println!(" First Name: {}", first_name);
}
if let Some(last_name) = &profile.identity.last_name {
println!(" Last Name: {}", last_name);
}
if let Some(email) = &profile.identity.email {
println!(" Email: {}", email);
}
if let Some(avatar) = &profile.identity.avatar_url {
println!(" Avatar URL: {}", avatar);
}
if let Some(bio) = &profile.identity.bio {
println!(" Bio: {}", bio);
}
println!(" Preferences:");
if let Some(theme) = &profile.preferences.theme {
println!(" Theme: {}", theme);
}
if let Some(language) = &profile.preferences.language {
println!(" Language: {}", language);
}
println!(" Notifications: {}", if profile.preferences.notifications_enabled { "Enabled" } else { "Disabled" });
println!(" Auto-sync: {}", if profile.preferences.auto_sync { "Enabled" } else { "Disabled" });
}
Ok(())
}
pub fn handle_profile_create(
&self,
file: &str,
hub_did: Option<String>,
handle: Option<String>,
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
theme: Option<String>,
language: Option<String>,
notifications: bool,
auto_sync: bool,
) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let mut passport = import_use_case.execute(file, &password, None)?;
let identity = UserIdentity {
handle,
display_name,
first_name,
last_name,
email,
avatar_url,
bio,
};
let preferences = UserPreferences {
theme,
language,
notifications_enabled: notifications,
auto_sync,
};
let create_use_case = CreateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
create_use_case.execute(
&mut passport,
hub_did.clone(),
identity,
preferences,
&password,
file,
)?;
println!("✅ User profile created successfully!");
if let Some(hub_did) = hub_did {
println!("📡 Hub DID: {}", hub_did);
} else {
println!("🏠 Profile Type: Default");
}
Ok(())
}
pub fn handle_profile_update(
&self,
file: &str,
id: &str,
hub_did: Option<String>,
handle: Option<String>,
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
theme: Option<String>,
language: Option<String>,
notifications: Option<bool>,
auto_sync: Option<bool>,
) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let mut passport = import_use_case.execute(file, &password, None)?;
// Get existing profile by ID
let existing_profile = passport.user_profile_by_id(id)
.ok_or_else(|| ApplicationError::UseCaseError("User profile not found".to_string()))?;
let identity = UserIdentity {
handle: handle.or_else(|| existing_profile.identity.handle.clone()),
display_name: display_name.or_else(|| existing_profile.identity.display_name.clone()),
first_name: first_name.or_else(|| existing_profile.identity.first_name.clone()),
last_name: last_name.or_else(|| existing_profile.identity.last_name.clone()),
email: email.or_else(|| existing_profile.identity.email.clone()),
avatar_url: avatar_url.or_else(|| existing_profile.identity.avatar_url.clone()),
bio: bio.or_else(|| existing_profile.identity.bio.clone()),
};
let preferences = UserPreferences {
theme: theme.or_else(|| existing_profile.preferences.theme.clone()),
language: language.or_else(|| existing_profile.preferences.language.clone()),
notifications_enabled: notifications.unwrap_or(existing_profile.preferences.notifications_enabled),
auto_sync: auto_sync.unwrap_or(existing_profile.preferences.auto_sync),
};
// Clone values before using them in multiple places
let identity_clone = identity.clone();
let preferences_clone = preferences.clone();
let hub_did_clone = hub_did.clone();
// Create updated profile with new hub_did if provided
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| ApplicationError::UseCaseError(format!("Time error: {}", e)))?
.as_secs();
let _profile = sharenet_passport::domain::entities::UserProfile {
id: existing_profile.id.clone(),
hub_did: hub_did.or_else(|| existing_profile.hub_did.clone()),
identity,
preferences,
created_at: existing_profile.created_at,
updated_at: now,
};
// Use the update use case to handle the profile update and file saving
let update_use_case = UpdateUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
update_use_case.execute(
&mut passport,
Some(id),
identity_clone,
preferences_clone,
&password,
file,
)?;
println!("✅ User profile updated successfully!");
if let Some(hub_did) = hub_did_clone {
println!("📡 Hub DID: {}", hub_did);
} else {
println!("🏠 Profile Type: Default");
}
Ok(())
}
pub fn handle_profile_delete(&self, file: &str, id: &str) -> Result<(), ApplicationError> {
let password = prompt_password("Enter password for passport file: ")
.map_err(|e| ApplicationError::UseCaseError(format!("Failed to read password: {}", e)))?;
let import_use_case = ImportFromFileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
let mut passport = import_use_case.execute(file, &password, None)?;
// Use the delete use case to handle the profile removal and file saving
let delete_use_case = DeleteUserProfileUseCase::new(
XChaCha20FileEncryptor,
FileSystemStorage,
);
delete_use_case.execute(
&mut passport,
Some(id),
&password,
file,
)?;
println!("✅ User profile deleted successfully!");
println!("🆔 Profile ID: {}", id);
Ok(())
}
}

View file

@ -10,11 +10,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let interface = CliInterface::new();
match cli.command {
Commands::Create { output } => {
interface.handle_create(&output)?;
Commands::Create { universe, output } => {
interface.handle_create(&universe, &output)?;
}
Commands::ImportRecovery { output } => {
interface.handle_import_recovery(&output)?;
Commands::CreateUniverse { name } => {
interface.handle_create_universe(&name)?;
}
Commands::ImportRecovery { universe, output } => {
interface.handle_import_recovery(&universe, &output)?;
}
Commands::ImportFile { input, output } => {
interface.handle_import_file(&input, output.as_deref())?;
@ -28,6 +31,80 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Sign { file, message } => {
interface.handle_sign(&file, &message)?;
}
Commands::Profile { command } => {
match command {
crate::cli::commands::ProfileCommands::List { file } => {
interface.handle_profile_list(&file)?;
}
crate::cli::commands::ProfileCommands::Create {
file,
hub_did,
handle,
display_name,
first_name,
last_name,
email,
avatar_url,
bio,
theme,
language,
notifications,
auto_sync,
} => {
interface.handle_profile_create(
&file,
hub_did,
handle,
display_name,
first_name,
last_name,
email,
avatar_url,
bio,
theme,
language,
notifications,
auto_sync,
)?;
}
crate::cli::commands::ProfileCommands::Update {
file,
id,
hub_did,
handle,
display_name,
first_name,
last_name,
email,
avatar_url,
bio,
theme,
language,
notifications,
auto_sync,
} => {
interface.handle_profile_update(
&file,
&id,
hub_did,
handle,
display_name,
first_name,
last_name,
email,
avatar_url,
bio,
theme,
language,
notifications,
auto_sync,
)?;
}
crate::cli::commands::ProfileCommands::Delete { file, id } => {
interface.handle_profile_delete(&file, &id)?;
}
}
}
}
Ok(())

View file

@ -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:<name>:<uuidv7>`.
## 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 nodes group key or gate.
- **Trustset**: CEK encrypted to each trusted nodes group key.
- **Hub**: CEK encrypted to a hubs group key or gate.
- **Federation**: CEK encrypted to each trusted hubs 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 Passports 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:<name>:<uuidv7>
```
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
- `<name>` - UTF-8 encoded universe name (any length)
- `<uuidv7>` - 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 users 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": <recipient_id>, "enc": <hpke_encap> }, ... ],
"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": <net_id>,
"univ": <univ_id>,
"ts": <timestamp>,
"revoked": [ <rid1>, <rid2>, ... ]
}
```
- 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 <package_hash>
@ -451,25 +465,25 @@ syspm install <package_hash>
---
## 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": <user_did>,
"iss": <node_did>,
"iss": <hub_did>,
"claim": "membership",
"net": <net_id>,
"univ": <univ_id>,
"ts": <issued_timestamp>,
"exp": <expiry_timestamp>
},
"sig": <signature_by_node>
"sig": <signature_by_hub>
}
```
### 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 users DID.
- Fetch the users membership VC for the target node/network.
- Fetch the users membership VC for the target hub/universe.
- Check the VCs 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:<name>:<uuidv7>`
- 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": <rid>,
"new_rid": <rid>,
"ts": <timestamp>,
"sig": <signature_by_node>
"sig": <signature_by_hub>
}
```
- 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 issuers 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.

View file

@ -0,0 +1,105 @@
use sharenet_passport::application::use_cases::*;
use sharenet_passport::domain::entities::*;
use sharenet_passport::infrastructure::crypto::*;
use sharenet_passport::infrastructure::storage::*;
use std::fs;
fn main() {
println!("Testing universe binding implementation...");
let mnemonic_generator = Bip39MnemonicGenerator;
let key_deriver = Ed25519KeyDeriver;
let file_encryptor = XChaCha20FileEncryptor;
let file_storage = FileSystemStorage;
let create_use_case = CreatePassportUseCase::new(
mnemonic_generator.clone(),
key_deriver.clone(),
file_encryptor.clone(),
file_storage.clone(),
);
// Test 1: Create passports for different universes
println!("\nTest 1: Creating passports for different universes...");
let univ1 = "univ:test:alpha";
let univ2 = "univ:test:beta";
let password = "test_password";
let (passport1, recovery_phrase) = create_use_case
.execute(univ1, password, "/tmp/test_passport1.spf")
.expect("Failed to create passport 1");
println!("✓ Passport 1 created for universe: {}", passport1.univ_id());
println!(" DID: {}", passport1.did().as_str());
let import_use_case = ImportFromRecoveryUseCase::new(
mnemonic_generator,
key_deriver,
file_encryptor,
file_storage,
);
let passport2 = import_use_case
.execute(
univ2,
&recovery_phrase.words(),
password,
"/tmp/test_passport2.spf",
)
.expect("Failed to create passport 2");
println!("✓ Passport 2 created for universe: {}", passport2.univ_id());
println!(" DID: {}", passport2.did().as_str());
// Verify universe binding
assert_eq!(passport1.univ_id(), univ1);
assert_eq!(passport2.univ_id(), univ2);
assert_ne!(passport1.did().as_str(), passport2.did().as_str());
assert_ne!(
hex::encode(&passport1.public_key().0),
hex::encode(&passport2.public_key().0)
);
println!("✓ Universe binding verified - different universes produce different identities");
// Test 2: Universe-bound card signing
println!("\nTest 2: Testing universe-bound card signing...");
let sign_use_case = SignCardUseCase::new();
let message = "Hello, universe!";
let signature = sign_use_case
.execute(&passport1, message)
.expect("Failed to sign message");
println!("✓ Message signed successfully");
// Test 3: Verify PassportFile stores univ_id
println!("\nTest 3: Verifying PassportFile stores univ_id...");
let loaded_file = FileSystemStorage
.load("/tmp/test_passport1.spf")
.expect("Failed to load passport file");
assert_eq!(loaded_file.univ_id, univ1);
println!("✓ PassportFile correctly stores univ_id: {}", loaded_file.univ_id);
// Test 4: Import from file preserves univ_id
println!("\nTest 4: Testing import from file preserves univ_id...");
let import_file_use_case = ImportFromFileUseCase::new(XChaCha20FileEncryptor, FileSystemStorage);
let imported_passport = import_file_use_case
.execute("/tmp/test_passport1.spf", password, None)
.expect("Failed to import passport");
assert_eq!(imported_passport.univ_id(), univ1);
println!("✓ Imported passport preserves univ_id: {}", imported_passport.univ_id());
// Clean up
let _ = fs::remove_file("/tmp/test_passport1.spf");
let _ = fs::remove_file("/tmp/test_passport2.spf");
println!("\n🎉 All universe binding tests passed successfully!");
println!("\nSummary:");
println!("- Passports are cryptographically bound to their universe");
println!("- Same mnemonic + different universe = different identities");
println!("- DIDs include universe identifier");
println!("- Card signatures are universe-bound");
println!("- Passport files store univ_id for verification");
}