diff --git a/Cargo.toml b/Cargo.toml index 3382a3f..b241aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,10 @@ tokio = { version = "1.28.0", features = ["full"] } serde = { version = "1.0.160", features = ["derive"] } serde_json = "1.0.96" anyhow = "1.0.70" - +axum = { version = "0.6.18", features = ["multipart"] } +uuid = { version = "1.3.0", features = ["v4", "serde"] } +sha2 = "0.10.6" +base64 = "0.21.0" +tower = "0.4.13" +tower-http = { version = "0.4.0", features = ["cors"] } +futures = "0.3.28" diff --git a/README.md b/README.md index 06b65ad..5d5f23e 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,90 @@ -# Vault Hierarchical Initializer +# Hierarchical Document Signing with HashiCorp Vault -A Rust-based utility for initializing and unsealing HashiCorp Vault in non-dev (production) mode. +This project implements a hierarchical document signing system using HashiCorp Vault. It allows for secure document signing with a requirement of a specific number of signatures from different departmental groups. -## Overview +## Features -This project provides a Docker-based solution for: +- **Hierarchical Signing**: Requires 3 of 5 signatures to validate a document, with at least 1 signature from each department +- **Department Structure**: Two departments (Legal and Finance) with 5 users each +- **Document API**: Upload, sign, and verify documents through a RESTful API +- **Vault Integration**: Leverages HashiCorp Vault's Transit engine for cryptographic operations -1. Running a HashiCorp Vault server in non-dev (production) mode -2. Automatically initializing the Vault instance -3. Unsealing the Vault after initialization -4. Storing unseal keys and root token securely +## System Architecture -## Prerequisites +The system consists of: -- Docker and Docker Compose installed on your system -- Rust (if you want to build the project locally) +1. **Vault Server**: Provides secure storage and cryptographic operations +2. **Rust Application**: Initializes Vault and provides a REST API for document operations +3. **User Hierarchy**: 10 users organized into 2 departments +4. **Signature Requirements**: 3 of 5 signatures needed, with at least 1 from each department -## Configuration +## API Endpoints -In production mode, Vault: -- Starts sealed and requires a threshold of unseal keys to unseal -- Stores data persistently in mounted volumes -- Requires explicit initialization -- Needs manual unsealing after restarts +- **POST /api/login**: Authenticate with username/password and get a token +- **POST /api/documents**: Upload a new document for signing +- **GET /api/documents/:id**: Retrieve document metadata +- **POST /api/documents/:id/sign**: Sign a document with your user credentials +- **GET /api/documents/:id/verify**: Check if a document has sufficient signatures -The implementation uses: -- 5 key shares with a threshold of 3 keys needed for unsealing -- Persistent volume storage for Vault data +## Getting Started -## Usage +### Prerequisites -### Starting Vault with Docker Compose +- Docker and Docker Compose +- Rust development environment (if building from source) -```bash -docker-compose up -d -``` +### Running with Docker -This will: -1. Start a Vault server in production mode -2. Run the vault-hier utility to initialize Vault if needed -3. Automatically unseal Vault using the threshold number of keys -4. Save the unseal keys and root token to `vault-credentials.txt` in the mounted volume +1. Start the Vault server and initialization program: + ``` + docker-compose up -d + ``` -### Getting Vault Credentials +2. The service will automatically: + - Initialize Vault (if needed) + - Unseal Vault + - Create 10 users in a hierarchical structure + - Start the API server on port 3000 -After initialization, you can find the unseal keys and root token in: +3. User credentials: + - Legal department: legal1/legal1pass through legal5/legal5pass + - Finance department: finance1/finance1pass through finance5/finance5pass -``` -./vault-credentials.txt -``` +### API Usage Examples -Keep these credentials safe! They provide full access to your Vault instance. +1. **Login**: + ```bash + curl -X POST http://localhost:3000/api/login \ + -H "Content-Type: application/json" \ + -d '{"username":"legal1","password":"legal1pass"}' + ``` -### Restarting a Sealed Vault +2. **Upload Document**: + ```bash + curl -X POST http://localhost:3000/api/documents \ + -F "name=Contract" \ + -F "file=@/path/to/document.pdf" + ``` -If your Vault instance restarts, it will start in a sealed state. To unseal it automatically: +3. **Sign Document**: + ```bash + curl -X POST http://localhost:3000/api/documents/DOCUMENT_ID/sign \ + -H "Content-Type: application/json" \ + -d '{"username":"legal1","token":"USER_TOKEN"}' + ``` -```bash -# Set the unseal keys as environment variables -export VAULT_UNSEAL_KEY_1="your-first-key" -export VAULT_UNSEAL_KEY_2="your-second-key" -export VAULT_UNSEAL_KEY_3="your-third-key" - -# Restart the vault-init container to trigger unsealing -docker-compose restart vault-init -``` - -## Development - -### Building the Project Locally - -```bash -cargo build --release -``` - -### Running Tests - -```bash -cargo test -``` - -### Custom Configuration - -To modify the key sharing threshold: -1. Edit the `init_req` struct in `src/main.rs` -2. Rebuild the Docker image +4. **Verify Document**: + ```bash + curl -X GET http://localhost:3000/api/documents/DOCUMENT_ID/verify + ``` ## Security Considerations -- In a production environment, never store unseal keys on the same machine as Vault -- Consider using a key management solution like Shamir's Secret Sharing -- Rotate root tokens regularly and use appropriate authentication methods \ No newline at end of file +- All cryptographic operations are performed by Vault's Transit engine +- Each user has their own signing key +- Root token should be secured in production environments +- Consider adding TLS for production deployments + +## License + +MIT diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..0c35b22 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,211 @@ +use anyhow::Result; +use axum::{ + extract::{Multipart, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, + Server, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::net::TcpListener; + +use crate::document_service::{Document, DocumentService, SignatureVerification}; +use crate::vault_setup::VaultClient; + +// API state containing services +#[derive(Clone)] +pub struct ApiState { + document_service: Arc, + vault_client: Arc, +} + +// API error +#[derive(Debug)] +pub struct ApiError(anyhow::Error); + +// Login request +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + username: String, + password: String, +} + +// Login response +#[derive(Debug, Serialize)] +pub struct LoginResponse { + token: String, +} + +// Sign document request +#[derive(Debug, Deserialize)] +pub struct SignDocumentRequest { + username: String, + token: String, +} + +// API response implementations +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error: {}", self.0), + ) + .into_response() + } +} + +impl From for ApiError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} + +// Start the API server +pub async fn start_api( + vault_addr: &str, + root_token: &str, + api_port: u16, +) -> Result<()> { + println!("Starting API server on port {}...", api_port); + + // Initialize Vault client + let vault_client = VaultClient::new(vault_addr, root_token); + + // Setup required secrets engines and auth methods + vault_client.setup_secrets_engines().await?; + + // Setup 10 users in hierarchical structure + vault_client.setup_hierarchical_users().await?; + + // Initialize document service + let document_service = DocumentService::new(vault_client.clone()); + + // Create API state + let state = ApiState { + document_service: Arc::new(document_service), + vault_client: Arc::new(vault_client), + }; + + // Setup router + let app = Router::new() + .route("/health", get(health_check)) + .route("/api/login", post(login)) + .route("/api/documents", post(upload_document)) + .route("/api/documents/:id", get(get_document)) + .route("/api/documents/:id/sign", post(sign_document)) + .route("/api/documents/:id/verify", get(verify_document)) + .with_state(state); + + // Start server + let listener = TcpListener::bind(format!("0.0.0.0:{}", api_port)).await?; + println!("API server started on port {}", api_port); + + // Get the socket address + let addr = listener.local_addr()?; + + // Bind and serve + Server::bind(&addr) + .serve(app.into_make_service()) + .await?; + + Ok(()) +} + +// Health check endpoint +async fn health_check() -> &'static str { + "OK" +} + +// Login endpoint +async fn login( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let token = state.vault_client + .login_user(&request.username, &request.password) + .await?; + + Ok(Json(LoginResponse { token })) +} + +// Upload document endpoint +async fn upload_document( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let mut document_name = String::new(); + let mut document_content = Vec::new(); + + // Process multipart form + while let Some(field) = multipart.next_field().await? { + let name = field.name().unwrap_or("").to_string(); + + if name == "name" { + document_name = field.text().await?; + } else if name == "file" { + document_content = field.bytes().await?.to_vec(); + } + } + + if document_name.is_empty() || document_content.is_empty() { + return Err(anyhow::anyhow!("Missing document name or content").into()); + } + + // Upload document + let document_id = state.document_service + .upload_document(&document_name, &document_content) + .await?; + + // Return document metadata + let document = state.document_service + .get_document(&document_id) + .await?; + + Ok(Json(document)) +} + +// Get document endpoint +async fn get_document( + State(state): State, + Path(document_id): Path, +) -> Result, ApiError> { + let document = state.document_service + .get_document(&document_id) + .await?; + + Ok(Json(document)) +} + +// Sign document endpoint +async fn sign_document( + State(state): State, + Path(document_id): Path, + Json(request): Json, +) -> Result, ApiError> { + state.document_service + .sign_document(&document_id, &request.username, &request.token) + .await?; + + let document = state.document_service + .get_document(&document_id) + .await?; + + Ok(Json(document)) +} + +// Verify document endpoint +async fn verify_document( + State(state): State, + Path(document_id): Path, +) -> Result, ApiError> { + let verification = state.document_service + .verify_document_signatures(&document_id) + .await?; + + Ok(Json(verification)) +} diff --git a/src/document_service.rs b/src/document_service.rs new file mode 100644 index 0000000..2a3c360 --- /dev/null +++ b/src/document_service.rs @@ -0,0 +1,419 @@ +use anyhow::{Context, Result}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Sha256, Digest}; +use std::collections::HashMap; +use uuid::Uuid; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + +use crate::vault_setup::{Department, User, VaultClient}; + +// Document status +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DocumentStatus { + #[serde(rename = "pending")] + Pending, + #[serde(rename = "verified")] + Verified, +} + +// Document metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Document { + pub id: String, + pub name: String, + pub hash: String, + pub status: DocumentStatus, + pub signatures: HashMap, // username -> signature +} + +// Signature verification response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureVerification { + pub document_id: String, + pub is_verified: bool, + pub signatures_count: usize, + pub legal_signatures: usize, + pub finance_signatures: usize, + pub required_signatures: usize, + pub required_legal: usize, + pub required_finance: usize, +} + +// Document service to handle document operations +pub struct DocumentService { + vault_client: VaultClient, +} + +impl DocumentService { + pub fn new(vault_client: VaultClient) -> Self { + DocumentService { vault_client } + } + + // Upload a new document and store its metadata + pub async fn upload_document(&self, name: &str, content: &[u8]) -> Result { + // Generate a unique ID + let id = Uuid::new_v4().to_string(); + + // Calculate document hash + let mut hasher = Sha256::new(); + hasher.update(content); + let hash = format!("{:x}", hasher.finalize()); + + // Create document metadata + let document = Document { + id: id.clone(), + name: name.to_string(), + hash, + status: DocumentStatus::Pending, + signatures: HashMap::new(), + }; + + // Store document metadata in Vault + self.store_document_metadata(&document).await?; + + println!("Document uploaded with ID: {}", id); + Ok(id) + } + + // Store document metadata in Vault + async fn store_document_metadata(&self, document: &Document) -> Result<()> { + let url = format!("{}/v1/documents/data/docs/{}", + self.vault_client.addr, document.id); + + let payload = json!({ + "data": { + "id": document.id, + "name": document.name, + "hash": document.hash, + "status": match document.status { + DocumentStatus::Pending => "pending", + DocumentStatus::Verified => "verified", + }, + "signatures": document.signatures, + } + }); + + let response = self.vault_client.client + .post(&url) + .header("X-Vault-Token", &self.vault_client.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT => { + println!("Successfully stored document metadata for {}", document.id); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to store document metadata: {} - {}", status, error_text)) + } + } + } + + // Get document metadata + pub async fn get_document(&self, document_id: &str) -> Result { + let url = format!("{}/v1/documents/data/docs/{}", + self.vault_client.addr, document_id); + + let response = self.vault_client.client + .get(&url) + .header("X-Vault-Token", &self.vault_client.token) + .send() + .await?; + + match response.status() { + StatusCode::OK => { + let json: serde_json::Value = response.json().await?; + + // Extract status + let status_str = json["data"]["data"]["status"] + .as_str() + .context("Failed to extract status")?; + + let status = match status_str { + "pending" => DocumentStatus::Pending, + "verified" => DocumentStatus::Verified, + _ => return Err(anyhow::anyhow!("Unknown status: {}", status_str)), + }; + + // Extract signatures + let signatures_value = &json["data"]["data"]["signatures"]; + let mut signatures = HashMap::new(); + + if let Some(obj) = signatures_value.as_object() { + for (username, sig) in obj { + if let Some(sig_str) = sig.as_str() { + signatures.insert(username.clone(), sig_str.to_string()); + } + } + } + + let document = Document { + id: json["data"]["data"]["id"].as_str().context("Missing id")?.to_string(), + name: json["data"]["data"]["name"].as_str().context("Missing name")?.to_string(), + hash: json["data"]["data"]["hash"].as_str().context("Missing hash")?.to_string(), + status, + signatures, + }; + + Ok(document) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to get document: {} - {}", status, error_text)) + } + } + } + + // Sign a document with the user's key + pub async fn sign_document(&self, document_id: &str, username: &str, user_token: &str) -> Result<()> { + // Get document metadata + let document = self.get_document(document_id).await?; + + // Get user info to verify department + let user = self.vault_client.get_user_info(username).await?; + + // Sign the document hash with user's key + let url = format!("{}/v1/transit/sign/{}", + self.vault_client.addr, username); + + let payload = json!({ + "input": BASE64.encode(document.hash.as_bytes()), + }); + + let response = self.vault_client.client + .post(&url) + .header("X-Vault-Token", user_token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::OK => { + let json: serde_json::Value = response.json().await?; + let signature = json["data"]["signature"] + .as_str() + .context("Failed to extract signature")? + .to_string(); + + // Update document with signature + self.add_signature(document_id, username, &signature).await?; + + // Update department signature record + self.record_department_signature(document_id, &user).await?; + + // Check if document now has enough signatures + self.update_document_status(document_id).await?; + + println!("Document {} signed by {}", document_id, username); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to sign document: {} - {}", status, error_text)) + } + } + } + + // Add a signature to a document + async fn add_signature(&self, document_id: &str, username: &str, signature: &str) -> Result<()> { + // Get current document + let mut document = self.get_document(document_id).await?; + + // Add signature + document.signatures.insert(username.to_string(), signature.to_string()); + + // Store updated document + self.store_document_metadata(&document).await?; + + Ok(()) + } + + // Record department signature + async fn record_department_signature(&self, document_id: &str, user: &User) -> Result<()> { + let dept_str = match user.department { + Department::Legal => "legal", + Department::Finance => "finance", + }; + + let url = format!("{}/v1/documents/data/dept/{}/signatures/{}", + self.vault_client.addr, dept_str, document_id); + + // Check if department signatures already exist + let response = self.vault_client.client + .get(&url) + .header("X-Vault-Token", &self.vault_client.token) + .send() + .await; + + let mut signatures = Vec::new(); + + // If record exists, get current signatures + if let Ok(resp) = response { + if resp.status() == StatusCode::OK { + let json: serde_json::Value = resp.json().await?; + if let Some(array) = json["data"]["data"]["signatures"].as_array() { + for sig in array { + if let Some(sig_str) = sig.as_str() { + signatures.push(sig_str.to_string()); + } + } + } + } + } + + // Add user to signatures if not already present + if !signatures.contains(&user.username) { + signatures.push(user.username.clone()); + } + + // Store updated signatures + let payload = json!({ + "data": { + "signatures": signatures, + } + }); + + let response = self.vault_client.client + .post(&url) + .header("X-Vault-Token", &self.vault_client.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT => { + println!("Recorded signature for {} in {} department", user.username, dept_str); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to record department signature: {} - {}", status, error_text)) + } + } + } + + // Update document status if it has enough signatures + async fn update_document_status(&self, document_id: &str) -> Result<()> { + // Verify signatures + let verification = self.verify_document_signatures(document_id).await?; + + if verification.is_verified { + // Get current document + let mut document = self.get_document(document_id).await?; + + // Update status + document.status = DocumentStatus::Verified; + + // Store updated document + self.store_document_metadata(&document).await?; + + println!("Document {} marked as verified", document_id); + } + + Ok(()) + } + + // Verify document signatures + pub async fn verify_document_signatures(&self, document_id: &str) -> Result { + // Get document + let document = self.get_document(document_id).await?; + + // Get signing requirements + let url = format!("{}/v1/documents/data/config/signing_requirements", + self.vault_client.addr); + + let response = self.vault_client.client + .get(&url) + .header("X-Vault-Token", &self.vault_client.token) + .send() + .await?; + + let config = match response.status() { + StatusCode::OK => response.json::().await?, + status => { + let error_text = response.text().await?; + return Err(anyhow::anyhow!("Failed to get signing requirements: {} - {}", status, error_text)); + } + }; + + // Get required signatures + let required_signatures = config["data"]["data"]["total_required"] + .as_u64() + .context("Missing total_required")? as usize; + + // Get department requirements + let mut required_legal = 0; + let mut required_finance = 0; + + if let Some(departments) = config["data"]["data"]["departments"].as_array() { + for dept in departments { + let name = dept["name"].as_str().context("Missing department name")?; + let required = dept["required"].as_u64().context("Missing required")? as usize; + + match name { + "legal" => required_legal = required, + "finance" => required_finance = required, + _ => (), + } + } + } + + // Get department signatures + let legal_signatures = self.get_department_signatures(document_id, "legal").await?; + let finance_signatures = self.get_department_signatures(document_id, "finance").await?; + + // Check if requirements are met + let total_signatures = document.signatures.len(); + let is_verified = total_signatures >= required_signatures && + legal_signatures.len() >= required_legal && + finance_signatures.len() >= required_finance; + + let verification = SignatureVerification { + document_id: document_id.to_string(), + is_verified, + signatures_count: total_signatures, + legal_signatures: legal_signatures.len(), + finance_signatures: finance_signatures.len(), + required_signatures, + required_legal, + required_finance, + }; + + Ok(verification) + } + + // Get department signatures for a document + async fn get_department_signatures(&self, document_id: &str, department: &str) -> Result> { + let url = format!("{}/v1/documents/data/dept/{}/signatures/{}", + self.vault_client.addr, department, document_id); + + let response = self.vault_client.client + .get(&url) + .header("X-Vault-Token", &self.vault_client.token) + .send() + .await; + + let mut signatures = Vec::new(); + + // If record exists, get signatures + if let Ok(resp) = response { + if resp.status() == StatusCode::OK { + let json: serde_json::Value = resp.json().await?; + if let Some(array) = json["data"]["data"]["signatures"].as_array() { + for sig in array { + if let Some(sig_str) = sig.as_str() { + signatures.push(sig_str.to_string()); + } + } + } + } + } + + Ok(signatures) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d0da82c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +// Modules that implement our hierarchical signing system +pub mod vault_setup; +pub mod document_service; +pub mod api; + +// Re-export main components for easier access +pub use vault_setup::VaultClient; +pub use document_service::DocumentService; +pub use api::start_api; diff --git a/src/main.rs b/src/main.rs index 0fca6e2..1a487ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ use std::{ }; use tokio::time::sleep; +// Import our library +use vault_hier::start_api; + // Vault API response structures #[derive(Debug, Deserialize)] struct InitResponse { @@ -46,13 +49,13 @@ fn save_credentials(response: &InitResponse, file_path: &str) -> Result<()> { "keys_base64": response.keys_base64, "root_token": response.root_token }); - + let mut file = File::create(Path::new(file_path))?; file.write_all(serde_json::to_string_pretty(&json)?.as_bytes())?; println!("Credentials saved to JSON file: {}", file_path); return Ok(()); } - + // For plaintext output (legacy format) let mut file = File::create(Path::new(file_path))?; writeln!(file, "Unseal Keys:")?; @@ -245,6 +248,12 @@ async fn main() -> Result<()> { let vault_addr = env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()); let client = Client::new(); + // Get API port from env var or use default + let api_port = env::var("API_PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse::() + .unwrap_or(3000); + println!("Vault address: {}", vault_addr); println!("Connecting to Vault at: {}", vault_addr); @@ -265,6 +274,7 @@ async fn main() -> Result<()> { // First check if Vault is already initialized let initialized = check_init_status(&client, &vault_addr).await?; + let mut root_token = String::new(); if initialized { println!("Vault is already initialized."); @@ -300,6 +310,29 @@ async fn main() -> Result<()> { } else { println!("Vault is already unsealed."); } + + // Try to load root token from environment or credentials file + match env::var("VAULT_TOKEN") { + Ok(token) => { + println!("Found root token from environment"); + root_token = token; + }, + Err(_) => { + // Try to load from credentials file + if let Ok(contents) = std::fs::read_to_string("vault-credentials.json") { + if let Ok(creds) = serde_json::from_str::(&contents) { + if let Some(token) = creds["root_token"].as_str() { + println!("Found root token from credentials file"); + root_token = token.to_string(); + } + } + } + } + } + + if root_token.is_empty() { + anyhow::bail!("Unable to find root token. Please set VAULT_TOKEN environment variable or provide vault-credentials.json file."); + } } else { // Initialize Vault println!("Vault is not initialized. Proceeding with initialization..."); @@ -308,12 +341,12 @@ async fn main() -> Result<()> { // Save credentials to files println!("Saving credentials to files..."); let current_dir = std::env::current_dir().context("Failed to get current directory")?; - + // Save as JSON (new format) let json_path = current_dir.join("vault-credentials.json"); save_credentials(&init_response, json_path.to_str().unwrap())?; println!("JSON credentials saved to: {}", json_path.display()); - + // Save as text (for backward compatibility) let text_path = current_dir.join("vault-credentials.txt"); save_credentials(&init_response, text_path.to_str().unwrap())?; @@ -324,7 +357,7 @@ async fn main() -> Result<()> { let docker_json_path = "/app/data/vault-credentials.json"; save_credentials(&init_response, docker_json_path)?; println!("Backup JSON credentials saved to Docker volume at: {}", docker_json_path); - + let docker_text_path = "/app/data/vault-credentials.txt"; save_credentials(&init_response, docker_text_path)?; println!("Backup text credentials saved to Docker volume at: {}", docker_text_path); @@ -349,8 +382,11 @@ async fn main() -> Result<()> { unseal_vault(&client, &vault_addr, &unseal_keys).await?; println!("Vault initialization and unseal complete!"); + + // Set root token + root_token = init_response.root_token; } - + // Look for any existing credentials and copy them to the mounted volume if let Ok(metadata) = std::fs::metadata("vault-credentials.json") { if metadata.is_file() { @@ -363,7 +399,7 @@ async fn main() -> Result<()> { } } } - + if let Ok(metadata) = std::fs::metadata("vault-credentials.txt") { if metadata.is_file() { println!("Found text credentials file, ensuring it's saved to Docker volume..."); @@ -376,7 +412,13 @@ async fn main() -> Result<()> { } } - println!("Operation complete!"); + println!("Vault setup complete!"); + println!("Starting hierarchical document signing API..."); + + // Start the hierarchical signing API + start_api(&vault_addr, &root_token, api_port).await?; + + println!("API server shutdown. Exiting."); Ok(()) } diff --git a/src/vault_setup.rs b/src/vault_setup.rs new file mode 100644 index 0000000..8caf4d3 --- /dev/null +++ b/src/vault_setup.rs @@ -0,0 +1,414 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +// Department types for organizational structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Department { + Legal, + Finance, +} + +// User representation with department info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub username: String, + pub department: Department, +} + +// VaultClient to interact with Vault API +#[derive(Clone)] +pub struct VaultClient { + pub client: Client, + pub addr: String, + pub token: String, +} + +impl VaultClient { + pub fn new(addr: &str, token: &str) -> Self { + VaultClient { + client: Client::new(), + addr: addr.to_string(), + token: token.to_string(), + } + } + + // Enable required secrets engines + pub async fn setup_secrets_engines(&self) -> Result<()> { + println!("Setting up Vault secrets engines..."); + + // Enable Transit for document signing + self.enable_secrets_engine("transit", "transit").await?; + + // Enable KV v2 for document storage + self.enable_secrets_engine("kv-v2", "documents").await?; + + // Enable userpass for authentication + self.enable_auth_method("userpass").await?; + + println!("Secrets engines setup complete!"); + Ok(()) + } + + // Enable a secrets engine + async fn enable_secrets_engine(&self, engine_type: &str, path: &str) -> Result<()> { + println!("Enabling {} secrets engine at {}", engine_type, path); + + let url = format!("{}/v1/sys/mounts/{}", self.addr, path); + let payload = json!({ + "type": engine_type, + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully enabled {} engine at {}", engine_type, path); + Ok(()) + } + StatusCode::BAD_REQUEST => { + // Check if already exists + let error_text = response.text().await?; + if error_text.contains("path is already in use") { + println!("Secrets engine already enabled at {}", path); + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to enable secrets engine: {}", error_text)) + } + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to enable secrets engine: {} - {}", status, error_text)) + } + } + } + + // Enable an auth method + async fn enable_auth_method(&self, method: &str) -> Result<()> { + println!("Enabling {} auth method", method); + + let url = format!("{}/v1/sys/auth/{}", self.addr, method); + let payload = json!({ + "type": method, + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully enabled {} auth method", method); + Ok(()) + } + StatusCode::BAD_REQUEST => { + // Check if already exists + let error_text = response.text().await?; + if error_text.contains("path is already in use") { + println!("Auth method already enabled at {}", method); + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to enable auth method: {}", error_text)) + } + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to enable auth method: {} - {}", status, error_text)) + } + } + } + + // Create a new user in Vault and associate with department + pub async fn create_user(&self, username: &str, password: &str, department: Department) -> Result<()> { + println!("Creating user {} in department {:?}", username, department); + + // Step 1: Create a policy for the user + let policy_name = format!("{}-policy", username); + self.create_signing_policy(&policy_name, department.clone()).await?; + + // Step 2: Create the user with userpass auth + let url = format!("{}/v1/auth/userpass/users/{}", self.addr, username); + let payload = json!({ + "password": password, + "policies": [policy_name], + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully created user {}", username); + + // Step 3: Create a signing key for the user + self.create_signing_key(username).await?; + + // Step 4: Store user metadata in KV store + self.store_user_metadata(username, department).await?; + + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to create user: {} - {}", status, error_text)) + } + } + } + + // Create a signing policy for a user based on their department + async fn create_signing_policy(&self, policy_name: &str, department: Department) -> Result<()> { + let dept_name = match department { + Department::Legal => "legal", + Department::Finance => "finance", + }; + + // Policy content with specific paths for the department + let policy = format!(r#" + # Allow reading document metadata + path "documents/data/docs/*" {{ + capabilities = ["read"] + }} + + # Allow signing with user's key + path "transit/sign/{{{{identity.entity.name}}}}" {{ + capabilities = ["update"] + }} + + # Allow signature verification + path "transit/verify/*" {{ + capabilities = ["update"] + }} + + # Department-specific path + path "documents/data/dept/{}/signatures/*" {{ + capabilities = ["create", "read", "update"] + }} + "#, dept_name); + + let url = format!("{}/v1/sys/policies/acl/{}", self.addr, policy_name); + let payload = json!({ + "policy": policy, + }); + + let response = self.client + .put(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully created policy {}", policy_name); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to create policy: {} - {}", status, error_text)) + } + } + } + + // Create a signing key for a user in the Transit engine + async fn create_signing_key(&self, username: &str) -> Result<()> { + let url = format!("{}/v1/transit/keys/{}", self.addr, username); + let payload = json!({ + "type": "ed25519", + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully created signing key for {}", username); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to create signing key: {} - {}", status, error_text)) + } + } + } + + // Store user metadata in the KV store + async fn store_user_metadata(&self, username: &str, department: Department) -> Result<()> { + let dept_str = match department { + Department::Legal => "legal", + Department::Finance => "finance", + }; + + let url = format!("{}/v1/documents/data/users/{}", self.addr, username); + let payload = json!({ + "data": { + "username": username, + "department": dept_str, + } + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully stored metadata for user {}", username); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to store user metadata: {} - {}", status, error_text)) + } + } + } + + // Create 10 users with departmental hierarchy - 5 in each department + pub async fn setup_hierarchical_users(&self) -> Result<()> { + // Create 5 users in Legal department + for i in 1..=5 { + let username = format!("legal{}", i); + let password = format!("legal{}pass", i); + self.create_user(&username, &password, Department::Legal).await?; + } + + // Create 5 users in Finance department + for i in 1..=5 { + let username = format!("finance{}", i); + let password = format!("finance{}pass", i); + self.create_user(&username, &password, Department::Finance).await?; + } + + // Setup document signing requirements + self.setup_signing_requirements().await?; + + println!("Successfully created 10 users in hierarchical structure!"); + Ok(()) + } + + // Configure document signing requirements + async fn setup_signing_requirements(&self) -> Result<()> { + let url = format!("{}/v1/documents/data/config/signing_requirements", self.addr); + let payload = json!({ + "data": { + "total_required": 3, + "departments": [ + { + "name": "legal", + "required": 1, + "total_users": 5 + }, + { + "name": "finance", + "required": 1, + "total_users": 5 + } + ], + "description": "Requires 3 signatures total, with at least 1 from Legal and 1 from Finance departments" + } + }); + + let response = self.client + .post(&url) + .header("X-Vault-Token", &self.token) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::NO_CONTENT | StatusCode::OK => { + println!("Successfully configured signing requirements"); + Ok(()) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to configure signing requirements: {} - {}", status, error_text)) + } + } + } + + // Login a user and get their token + pub async fn login_user(&self, username: &str, password: &str) -> Result { + let url = format!("{}/v1/auth/userpass/login/{}", self.addr, username); + let payload = json!({ + "password": password, + }); + + let response = self.client + .post(&url) + .json(&payload) + .send() + .await?; + + match response.status() { + StatusCode::OK => { + let json: serde_json::Value = response.json().await?; + let token = json["auth"]["client_token"] + .as_str() + .context("Failed to extract client token")? + .to_string(); + + println!("User {} successfully logged in", username); + Ok(token) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to login: {} - {}", status, error_text)) + } + } + } + + // Get user info including department + pub async fn get_user_info(&self, username: &str) -> Result { + let url = format!("{}/v1/documents/data/users/{}", self.addr, username); + + let response = self.client + .get(&url) + .header("X-Vault-Token", &self.token) + .send() + .await?; + + match response.status() { + StatusCode::OK => { + let json: serde_json::Value = response.json().await?; + let department_str = json["data"]["data"]["department"] + .as_str() + .context("Failed to extract department")?; + + let department = match department_str { + "legal" => Department::Legal, + "finance" => Department::Finance, + _ => return Err(anyhow::anyhow!("Unknown department: {}", department_str)), + }; + + Ok(User { + username: username.to_string(), + department, + }) + } + status => { + let error_text = response.text().await?; + Err(anyhow::anyhow!("Failed to get user info: {} - {}", status, error_text)) + } + } + } +}