feat: add hierarchical document signing with Vault API

- Introduced a new hierarchical signing system using HashiCorp Vault.
- Added Rust modules for user management, secrets setup, and document API.
- Implemented API endpoints for login, document upload, signing, and verification.
- Updated README with features, usage, and API examples.
This commit is contained in:
Harald Hoyer 2025-03-20 14:39:22 +01:00
parent 0dc662865f
commit f11b83ddf4
7 changed files with 1177 additions and 79 deletions

View file

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

137
README.md
View file

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

211
src/api.rs Normal file
View file

@ -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<DocumentService>,
vault_client: Arc<VaultClient>,
}
// 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<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
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<ApiState>,
Json(request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<ApiState>,
mut multipart: Multipart,
) -> Result<Json<Document>, 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<ApiState>,
Path(document_id): Path<String>,
) -> Result<Json<Document>, ApiError> {
let document = state.document_service
.get_document(&document_id)
.await?;
Ok(Json(document))
}
// Sign document endpoint
async fn sign_document(
State(state): State<ApiState>,
Path(document_id): Path<String>,
Json(request): Json<SignDocumentRequest>,
) -> Result<Json<Document>, 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<ApiState>,
Path(document_id): Path<String>,
) -> Result<Json<SignatureVerification>, ApiError> {
let verification = state.document_service
.verify_document_signatures(&document_id)
.await?;
Ok(Json(verification))
}

419
src/document_service.rs Normal file
View file

@ -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<String, String>, // 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<String> {
// 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<Document> {
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<SignatureVerification> {
// 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::<serde_json::Value>().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<Vec<String>> {
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)
}
}

9
src/lib.rs Normal file
View file

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

View file

@ -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::<u16>()
.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::<serde_json::Value>(&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(())
}

414
src/vault_setup.rs Normal file
View file

@ -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<String> {
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<User> {
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))
}
}
}
}