Compare commits
6 commits
a38845ef0b
...
e0cc192841
| Author | SHA1 | Date | |
|---|---|---|---|
| e0cc192841 | |||
| 70dd82bac1 | |||
| af0d66d370 | |||
| 5eea31a25d | |||
| 7b60e8e8ea | |||
| 92f4319dbc |
19 changed files with 2814 additions and 208 deletions
149
Cargo.lock
generated
149
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -32,6 +38,3 @@ wasm = ["alloc"] # WASM target support
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
|
||||
[publish]
|
||||
registry = "sharenet"
|
||||
162
libs/sharenet-passport/README.md
Normal file
162
libs/sharenet-passport/README.md
Normal 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/
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
232
libs/sharenet-passport/src/universe_binding_test.rs
Normal file
232
libs/sharenet-passport/src/universe_binding_test.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
105
sharenet-passport-cli/src/bin/test_universe_binding.rs
Normal file
105
sharenet-passport-cli/src/bin/test_universe_binding.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
291
sharenet_spec.md
291
sharenet_spec.md
|
|
@ -7,8 +7,8 @@ This document specifies a decentralized protocol for the creation, distribution,
|
|||
This protocol assumes three fundamental building blocks in addition to Cards:
|
||||
|
||||
- **Passports**: User-held identifiers derived from cryptographic seeds.
|
||||
- **Nodes**: Servers that maintain membership, relay Cards, and enforce network policies.
|
||||
- **Networks**: Collections of nodes and users defined by a shared Genesis Document.
|
||||
- **Hubs**: Servers that maintain membership, relay Cards, and enforce network policies.
|
||||
- **Universes**: Collections of hubs and users defined by a shared self-describing universe identifier.
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
|
|
@ -23,9 +23,9 @@ This protocol assumes three fundamental building blocks in addition to Cards:
|
|||
- **Distribution**: Where the Card may be transmitted.
|
||||
- **Permanent public Card**: A Card with no encryption and no revocation path. Immutable.
|
||||
- **Passport**: A user-held self-sovereign identifier derived from a mnemonic seed.
|
||||
- **Node**: A server implementing this protocol and providing membership and relay functions.
|
||||
- **Network**: A collection of nodes and users sharing a Genesis Document.
|
||||
- **Genesis Document**: Immutable initial configuration defining a network.
|
||||
- **Hub**: A server implementing this protocol and providing membership and relay functions.
|
||||
- **Universe**: A collection of hubs and users sharing a self-describing universe identifier.
|
||||
- **Universe Identifier (univ_id)**: A human-readable DID that encodes both a human-readable name and unique identifier in the format `u:<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 node’s group key or gate.
|
||||
- **Trustset**: CEK encrypted to each trusted node’s group key.
|
||||
- **Hub**: CEK encrypted to a hub’s group key or gate.
|
||||
- **Federation**: CEK encrypted to each trusted hub’s group key.
|
||||
- **Group**: CEK encrypted to an MLS group. Roster MAY remain private.
|
||||
|
||||
## 6. Distribution Modes
|
||||
|
||||
- **to_recipients_only**: Honest nodes forward only to recipients.
|
||||
- **this_node_only**: Not gossiped beyond node.
|
||||
- **trusted_nodes**: Forwarded only to trustset.
|
||||
- **recipients**: Honest hubs forward only to recipients.
|
||||
- **hub**: Not gossiped beyond hub.
|
||||
- **federation**: Forwarded only to federation.
|
||||
- **public**: Freely gossiped. Encryption still protects confidentiality.
|
||||
|
||||
Distribution policies MAY be encrypted inside the policy capsule.
|
||||
|
|
@ -124,10 +124,10 @@ Distribution policies MAY be encrypted inside the policy capsule.
|
|||
## 8. Security Considerations
|
||||
|
||||
- All hashes MUST be computed over canonical CBOR.
|
||||
- Policy capsule MUST bind `{net, rid, payload_hash}` in AEAD AAD.
|
||||
- Policy capsule MUST bind `{univ, rid, payload_hash}` in AEAD AAD.
|
||||
- Bundles MUST be expanded before enforcement.
|
||||
- Unknown required caps MUST cause rejection.
|
||||
- Distribution enforcement relies on honest nodes; confidentiality relies on crypto.
|
||||
- Distribution enforcement relies on honest hubs; confidentiality relies on crypto.
|
||||
- Padding SHOULD be used to obscure audience sizes.
|
||||
|
||||
## 9. Extensibility
|
||||
|
|
@ -168,12 +168,12 @@ The public key is used to generate the DID and sign Cards.
|
|||
### 11.3 Usage
|
||||
- Cards are signed with the Passport’s Ed25519 private key.
|
||||
- Verification uses the DID → public key mapping.
|
||||
- Users MAY belong to multiple networks with the same Passport.
|
||||
- Users MAY NOT belong to multiple universes with the same Passport.
|
||||
|
||||
## 12. Nodes
|
||||
## 12. Hubs
|
||||
|
||||
### 12.1 Definition
|
||||
A **Node** is a server implementing the protocol, typically written in Rust.
|
||||
A **Hub** is a server implementing the protocol, typically written in Rust.
|
||||
It provides:
|
||||
|
||||
- Membership management (accepting/revoking Passports).
|
||||
|
|
@ -182,45 +182,59 @@ It provides:
|
|||
- Optional Key Gate services for strong revocation.
|
||||
|
||||
### 12.2 Membership
|
||||
- A user joins a Node by presenting a signed request with their Passport public DID.
|
||||
- The Node MAY issue a Verifiable Credential (VC) attesting to membership.
|
||||
- Users MAY leave a Node by revoking their VC locally.
|
||||
- Nodes MAY gossip membership VCs to peers.
|
||||
- A user joins a Hub by presenting a signed request with their Passport public DID.
|
||||
- The Hub MAY issue a Verifiable Credential (VC) attesting to membership.
|
||||
- Users MAY leave a Hub by revoking their VC locally.
|
||||
- Hubs MAY gossip membership VCs to peers.
|
||||
|
||||
### 12.3 Federation
|
||||
Nodes communicate using Cards.
|
||||
Distribution policies determine which nodes are eligible to receive a Card.
|
||||
Nodes MAY maintain trustsets to decide forwarding scopes.
|
||||
Hubs communicate using Cards.
|
||||
Distribution policies determine which hubs are eligible to receive a Card.
|
||||
Hubs MAY maintain federations to decide forwarding scopes.
|
||||
|
||||
## 13. Networks
|
||||
## 13. Universes
|
||||
|
||||
### 13.1 Genesis Document
|
||||
Each network begins with a **Genesis Document** that defines:
|
||||
### 13.1 Universe Identifier (univ_id)
|
||||
Each universe is identified by a **human-readable DID** that encodes both a human-readable name and a unique identifier.
|
||||
|
||||
- `net_id`: Unique network identifier.
|
||||
- `genesis_ts`: Timestamp of creation.
|
||||
- `founders`: Initial node and user DIDs.
|
||||
- `bootstrap_caps`: Minimal capability set required for participation.
|
||||
- `initial_policies`: Distribution and trust defaults.
|
||||
**Format:**
|
||||
```
|
||||
u:<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 user’s Passport seed is stolen, their identity is compromised until rotated.
|
||||
|
|
@ -236,7 +250,7 @@ Implementations MUST use the following algorithms unless explicitly negotiated b
|
|||
- **Hashing**: SHA-256 for `content_hash`, `policy_ref`, and capability IDs.
|
||||
- **KDFs**: HKDF-SHA256 for deriving CEKs and KEKs.
|
||||
- **Randomness**: CEKs MUST be generated with a CSPRNG; nonces MUST NOT repeat.
|
||||
- **AAD binding**: `{net, rid, content_hash, policy_ref}` MUST be included as Additional Authenticated Data in all AEAD operations.
|
||||
- **AAD binding**: `{univ, rid, content_hash, policy_ref}` MUST be included as Additional Authenticated Data in all AEAD operations.
|
||||
|
||||
## 16. Key Management & Rotation
|
||||
|
||||
|
|
@ -249,24 +263,24 @@ Implementations MUST use the following algorithms unless explicitly negotiated b
|
|||
### 16.2 Rotation
|
||||
- Users MAY rotate Passport keys by generating a new keypair from the seed with a different derivation path.
|
||||
- DID key rotation certificates MAY be issued: signed by both old and new keys, linking continuity.
|
||||
- Nodes and verifiers MUST accept a chain of rotation certificates when validating historical Cards.
|
||||
- Hubs and verifiers MUST accept a chain of rotation certificates when validating historical Cards.
|
||||
|
||||
### 16.3 Revocation
|
||||
- Revocation of Cards MAY occur via deletion of KEKs (crypto-shred) or via refusal to release CEKs (online gate).
|
||||
- Membership credentials (VCs) MUST include status lists or revocation registries to support user exit from nodes.
|
||||
- Membership credentials (VCs) MUST include status lists or revocation registries to support user exit from hubs.
|
||||
|
||||
## 17. Policy Capsule Wire Format
|
||||
|
||||
### 17.1 Encoding
|
||||
- The policy capsule is encoded as **CBOR**, then wrapped in **COSE_Encrypt0**.
|
||||
- Key encapsulation MAY use **HPKE** (per recipient) or **MLS** (per group).
|
||||
- The capsule MUST be integrity-protected with AEAD; AAD MUST include `{net, rid, payload_hash}`.
|
||||
- The capsule MUST be integrity-protected with AEAD; AAD MUST include `{univ, rid, payload_hash}`.
|
||||
|
||||
### 17.2 Structure (plaintext before encryption)
|
||||
```cbor
|
||||
{
|
||||
"vis": "public" | "direct" | "node" | "trustset" | "group",
|
||||
"dist": "to_recipients_only" | "this_node_only" | "trusted_nodes" | "public",
|
||||
"vis": "public" | "direct" | "hub" | "federation" | "group",
|
||||
"dist": "recipients" | "hub" | "federation" | "public",
|
||||
"keying": {
|
||||
"hpke": [ { "kid": <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 user’s DID.
|
||||
- Fetch the user’s membership VC for the target node/network.
|
||||
- Fetch the user’s membership VC for the target hub/universe.
|
||||
- Check the VC’s signature, validity window, and status list entry.
|
||||
- Historical Cards MUST remain valid if the VC was active at the time of creation.
|
||||
|
||||
## 26. Network Genesis, Publication, and Forks
|
||||
## 26. Universe Creation and Identification
|
||||
|
||||
### 26.1 Canonical Genesis
|
||||
- The Genesis Document MUST be encoded in canonical CBOR.
|
||||
- The `net_id` is computed as `SHA-256` of the canonical bytes.
|
||||
- Genesis MUST include:
|
||||
- `net_id`, `genesis_ts`, founders (node/user DIDs), bootstrap capabilities, and policies.
|
||||
- Genesis is immutable. Any change results in a new `net_id` and thus a new network.
|
||||
### 26.1 Universe Identifier Generation
|
||||
- A universe is created by generating a **human-readable DID**
|
||||
- The format MUST be: `u:<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 issuer’s signature against a trusted keyring.
|
||||
|
||||
### 35.4 Client Behavior
|
||||
|
|
@ -827,7 +832,7 @@ Implementations MUST validate against these vectors to ensure interoperability.
|
|||
- Consumers MUST reject Cards that require unknown or deprecated `cap_id`s.
|
||||
|
||||
### 36.5 Adapters
|
||||
- Nodes MAY provide **adapters** to transform Cards from older schema versions to newer ones.
|
||||
- Hubs MAY provide **adapters** to transform Cards from older schema versions to newer ones.
|
||||
- Adapters MUST be deterministic and SHOULD be distributed as signed schema packages.
|
||||
- Consumers MUST record the use of adapters in audit logs.
|
||||
|
||||
|
|
@ -837,7 +842,7 @@ Implementations MUST validate against these vectors to ensure interoperability.
|
|||
{
|
||||
"ver": 1,
|
||||
"type": "card",
|
||||
"net": "net:prod:z9…",
|
||||
"univ": "u:Production Universe:018f1234-5678-90ab-cdef-123456789abc",
|
||||
"rid": "0x12b…",
|
||||
"ts": 1738123456,
|
||||
"reqs": ["cap:card/v1@…"],
|
||||
|
|
@ -858,7 +863,7 @@ Implementations MUST validate against these vectors to ensure interoperability.
|
|||
{
|
||||
"ver": 1,
|
||||
"type": "card",
|
||||
"net": "net:prod:z9…",
|
||||
"univ": "u:Production Universe:018f1234-5678-90ab-cdef-123456789abc",
|
||||
"rid": "0x44a…",
|
||||
"ts": 1738126789,
|
||||
"reqs": ["cap:card/v2@…","cap:hpke-x25519-xc20p@…"],
|
||||
|
|
@ -951,7 +956,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
### 3.2 Schema Descriptors
|
||||
**Purpose:** Operator-friendly pointer to install a module out-of-band.
|
||||
|
||||
**Security:** MUST be **signed**. Node validates artifact hash before load.
|
||||
**Security:** MUST be **signed**. Hub validates artifact hash before load.
|
||||
|
||||
```cbor
|
||||
{
|
||||
|
|
@ -997,32 +1002,32 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
}
|
||||
```
|
||||
|
||||
### 3.4 Node Capability Manifests
|
||||
**Purpose:** What a node supports (caps, roles).
|
||||
### 3.4 Hub Capability Manifests
|
||||
**Purpose:** What a hub supports (caps, roles).
|
||||
|
||||
**Security:** SHOULD be **signed** and available at `/.well-known/node-caps`.
|
||||
**Security:** SHOULD be **signed** and available at `/.well-known/hub-caps`.
|
||||
|
||||
```cbor
|
||||
{
|
||||
"schema": "node-caps/v1",
|
||||
"net": tstr,
|
||||
"schema": "hub-caps/v1",
|
||||
"univ": tstr,
|
||||
"caps_supported": [ tstr, ... ],
|
||||
"roles": [ "relay", "archive", "light" ]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Membership Credentials (VC)
|
||||
**Purpose:** “Alice @ Node B” assertion for a given network.
|
||||
**Purpose:** “Alice @ Hub B” assertion for a given universe.
|
||||
|
||||
**Security:** MUST be **signed** by the issuing node.
|
||||
**Security:** MUST be **signed** by the issuing hub.
|
||||
|
||||
```cbor
|
||||
{
|
||||
"schema": "membership-vc/v1",
|
||||
"vc": {
|
||||
"sub": tstr, ; user DID
|
||||
"iss": tstr, ; node DID
|
||||
"net": tstr,
|
||||
"iss": tstr, ; hub DID
|
||||
"univ": tstr,
|
||||
"claim": "membership",
|
||||
"ts": uint,
|
||||
"exp": uint
|
||||
|
|
@ -1038,7 +1043,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
```cbor
|
||||
{
|
||||
"schema": "vc-status/v1",
|
||||
"net": tstr,
|
||||
"univ": tstr,
|
||||
"ts": uint,
|
||||
"entries": [ { "did": tstr, "status": "active" | "revoked" | "suspended", "exp": uint } ]
|
||||
}
|
||||
|
|
@ -1075,28 +1080,28 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
### 3.9 Gate Tokens / Requests (Optional)
|
||||
**Purpose:** Short-lived authorization to retrieve CEKs or stream decrypts.
|
||||
|
||||
**Security:** MUST be **signed**, bound to `{rid, net}`, have short TTL.
|
||||
**Security:** MUST be **signed**, bound to `{rid, univ}`, have short TTL.
|
||||
|
||||
```cbor
|
||||
{
|
||||
"schema": "gate-token/v1",
|
||||
"rid": bstr,
|
||||
"net": tstr,
|
||||
"univ": tstr,
|
||||
"aud": bstr,
|
||||
"exp": uint
|
||||
}
|
||||
```
|
||||
|
||||
### 3.10 Trustset Updates
|
||||
**Purpose:** Rotate which nodes are considered trusted for forwarding.
|
||||
### 3.10 Federation Updates
|
||||
**Purpose:** Rotate which hubs are considered trusted for forwarding.
|
||||
|
||||
**Security:** MUST be **signed** by local operator key.
|
||||
|
||||
```cbor
|
||||
{
|
||||
"schema": "trustset/v1",
|
||||
"schema": "federation/v1",
|
||||
"version": uint,
|
||||
"entries": [ { "node": tstr, "op": "add" | "remove" } ]
|
||||
"entries": [ { "hub": tstr, "op": "add" | "remove" } ]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1154,7 +1159,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
```cbor
|
||||
{
|
||||
"schema": "crl/v1",
|
||||
"net": tstr,
|
||||
"univ": tstr,
|
||||
"ts": uint,
|
||||
"revoked": [ bstr, ... ] ; list of rid
|
||||
}
|
||||
|
|
@ -1209,7 +1214,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
## 5. Transport Considerations
|
||||
|
||||
- All control-plane and governance objects MAY be gossiped like Cards or fetched on demand by ID (content-addressed).
|
||||
- Nodes SHOULD cache verified specs/packages and bulletins.
|
||||
- Hubs SHOULD cache verified specs/packages and bulletins.
|
||||
- Deduplicate by `(schema, primary id field)` to avoid reprocessing.
|
||||
- Objects MUST be small (typically <64KB), except blob metadata.
|
||||
|
||||
|
|
@ -1217,7 +1222,7 @@ This addendum enumerates object types beyond **Cards** that a compliant implemen
|
|||
|
||||
## 6. Security & Validation Rules (Normative)
|
||||
|
||||
- **Signature verification:** Required for all objects that change validation or trust (schema packages, caps, VCs, status lists, deprecations, bulletins, trustsets).
|
||||
- **Signature verification:** Required for all objects that change validation or trust (schema packages, caps, VCs, status lists, deprecations, bulletins, federations).
|
||||
- **Determinism:** Validators/normalizers MUST be pure and deterministic; no network/file I/O.
|
||||
- **Sandboxing:** Any executable validation logic MUST run in a WASM sandbox under strict CPU/mem/time budgets.
|
||||
- **Hash binding:** Where objects reference Cards or payloads, they MUST use `rid` or `content_hash` and verify before acting.
|
||||
|
|
|
|||
105
src/bin/test_universe_binding.rs
Normal file
105
src/bin/test_universe_binding.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue