Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525eb41104 | |||
| 636673a47b | |||
| 2b1f505b34 | |||
| 4c576c8ffe | |||
| 09843cbedf | |||
| da119a5c31 | |||
| 82f28a21ca | |||
| 946d3402bc | |||
| 87876a31d6 | |||
| bbf1e2531b | |||
| 400162a295 | |||
| 3e72a048dc | |||
| 165201b193 | |||
| 3a938191fd | |||
| 942a64834e | |||
| b7963b3b76 | |||
| 1aaf8b0a1f | |||
| 6c51d77572 | |||
| 95dc8f5e53 | |||
| 0c08361463 | |||
| 0fbb5d7418 | |||
| b371debc21 | |||
| c3240c43d5 | |||
| c59dd97f19 | |||
| 3e38a5522e | |||
| 3364bc47c5 | |||
| 74901c9fa4 | |||
| 27ab72f1fc | |||
| 3de69b62e0 | |||
| b7d7010c01 | |||
| d7b957ce4d | |||
| 553e1f1620 | |||
| e0d5c9872d |
8
.gitea/workflows/ci.yml
Normal file
8
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: echo "Checkout worked"
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will cover compiled files and intermediates
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Remove package-lock to avoid confusion
|
||||||
|
# (we use Cargo.lock exclusively)
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# These are backup files generated by the editor
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local vault files (contain sensitive data)
|
||||||
|
~/.config/api-token-vault/
|
||||||
1
Cargo.lock
generated
Normal file
1
Cargo.lock
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[lock file content]
|
||||||
27
Cargo.toml
Normal file
27
Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-token-vault"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A Rust CLI tool that generates cryptographically secure API tokens, stores them in an encrypted local vault, rotates them on configurable schedules, and injects them into .env files."
|
||||||
|
authors = ["API Token Vault"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
sodiumoxide = "0.2"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
|
dirs = "5.0"
|
||||||
|
home = "0.5"
|
||||||
|
base64 = "0.21"
|
||||||
|
rand = "0.8"
|
||||||
|
rpassword = "7.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2.0"
|
||||||
|
predicates = "3.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
210
README.md
210
README.md
@@ -1,3 +1,209 @@
|
|||||||
# api-token-vault
|
# API Token Vault
|
||||||
|
|
||||||
A Rust CLI tool that generates, securely stores, and automatically rotates API tokens for local development
|
[](https://7000pct.gitea.bloupla.net/7000pctAUTO/api-token-vault/actions)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
|
||||||
|
A Rust CLI tool that generates cryptographically secure API tokens, stores them in an encrypted local vault, rotates them on configurable schedules, and injects them into .env files. Provides multi-project isolation with separate vaults per project.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Secure Token Generation**: Generate cryptographically secure API tokens using libsodium
|
||||||
|
- **Encrypted Vault Storage**: All tokens stored encrypted using libsodium's secretbox
|
||||||
|
- **Auto-Rotation Schedules**: Configure automatic token rotation with configurable intervals
|
||||||
|
- **.env File Injection**: Inject tokens directly into .env files with custom prefixes
|
||||||
|
- **Multi-Project Isolation**: Separate vaults for different projects with independent passwords
|
||||||
|
- **Secure Key Derivation**: Uses Argon2id for deriving encryption keys from master passwords
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Crates.io
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install api-token-vault
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://7000pct.gitea.bloupla.net/7000pctAUTO/api-token-vault.git
|
||||||
|
cd api-token-vault
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary will be at `target/release/api-token-vault`.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Initialize a Vault
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault init --project my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set the project via environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export API_VAULT_PROJECT=my-project
|
||||||
|
api-token-vault init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate a Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault generate --name api_key --length 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### List All Tokens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get a Token Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault get --name api_key
|
||||||
|
api-token-vault get --name api_key --raw # Output only the token value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rotate a Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault rotate --name api_key
|
||||||
|
api-token-vault rotate --name api_key --force # Force rotation even if not due
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Auto-Rotation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault set-rotation --name api_key --days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Expired Tokens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault check-expired
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rotate All Expired Tokens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault rotate-expired
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inject Tokens into .env File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault inject --env-file .env
|
||||||
|
api-token-vault inject --env-file .env --dry-run # Preview changes without writing
|
||||||
|
api-token-vault inject --env-file .env --token-prefix MY_TOKEN_ # Custom prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api-token-vault delete --name api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `init` | Initialize a new vault for a project |
|
||||||
|
| `generate` | Generate a new secure API token |
|
||||||
|
| `list` | List all tokens in the vault |
|
||||||
|
| `get` | Get a specific token value |
|
||||||
|
| `delete` | Delete a token from the vault |
|
||||||
|
| `rotate` | Rotate (regenerate) a specific token |
|
||||||
|
| `set-rotation` | Set auto-rotation schedule for a token |
|
||||||
|
| `inject` | Inject tokens into a .env file |
|
||||||
|
| `check-expired` | Check for expired tokens |
|
||||||
|
| `rotate-expired` | Rotate all expired tokens |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `API_VAULT_PATH` | Custom path for vault storage directory |
|
||||||
|
| `API_VAULT_PROJECT` | Default project name (used when not specified via CLI) |
|
||||||
|
|
||||||
|
### Vault Location
|
||||||
|
|
||||||
|
By default, vaults are stored in:
|
||||||
|
- Linux/macOS: `~/.config/api-token-vault/`
|
||||||
|
- Windows: `%APPDATA%\api-token-vault\`
|
||||||
|
|
||||||
|
Each project has its own vault file: `~/.config/api-token-vault/{project_name}.json`
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Encryption**: Uses libsodium's secretbox for authenticated encryption
|
||||||
|
- **Key Derivation**: Uses Argon2id (via libsodium's pwhash) for key derivation
|
||||||
|
- **Master Password**: Required to access each vault
|
||||||
|
- **Salt**: Unique salt per vault for key derivation
|
||||||
|
|
||||||
|
## Token Formats
|
||||||
|
|
||||||
|
The tool can generate tokens in various formats:
|
||||||
|
- **Default**: Base64-encoded secure random bytes
|
||||||
|
- **Hex**: Hexadecimal encoded
|
||||||
|
- **Alphanumeric**: Letters and numbers only
|
||||||
|
- **API Key**: With custom prefix (e.g., `sk_live_xxxxx`)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
cargo test --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benchmarks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo bench
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
api-token-vault/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── Cargo.lock
|
||||||
|
├── README.md
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Entry point and command handling
|
||||||
|
│ ├── cli.rs # CLI argument parsing with clap
|
||||||
|
│ ├── vault.rs # Vault storage and management
|
||||||
|
│ ├── token.rs # Token generation and data structures
|
||||||
|
│ ├── rotation.rs # Token rotation scheduling
|
||||||
|
│ ├── env_injector.rs # .env file injection
|
||||||
|
│ └── crypto.rs # Cryptographic operations
|
||||||
|
└── tests/
|
||||||
|
└── integration_tests.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ authors = ["API Token Vault"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
sodiumoxide = "0.2"
|
aes-gcm = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
@@ -15,7 +17,6 @@ uuid = { version = "1.6", features = ["v4", "serde"] }
|
|||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
home = "0.5"
|
home = "0.5"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
rand = "0.8"
|
|
||||||
rpassword = "7.3"
|
rpassword = "7.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use sodiumoxide::crypto::secretbox;
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||||
use sodiumoxide::crypto::pwhash;
|
use aes_gcm::aead::{Aead, AeadCore, OsRng};
|
||||||
|
use sha2::Sha256;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -27,41 +28,35 @@ impl fmt::Display for CryptoError {
|
|||||||
impl std::error::Error for CryptoError {}
|
impl std::error::Error for CryptoError {}
|
||||||
|
|
||||||
pub struct CryptoManager {
|
pub struct CryptoManager {
|
||||||
key: secretbox::Key,
|
key: Key<Aes256Gcm>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CryptoManager {
|
impl CryptoManager {
|
||||||
pub fn from_password(password: &str, salt: &[u8; pwhash::SALTBYTES]) -> Result<Self, CryptoError> {
|
const KEY_SIZE: usize = 32;
|
||||||
let mut key = secretbox::Key::from_slice(&[0u8; secretbox::KEYBYTES])
|
|
||||||
.ok_or(CryptoError::InvalidKey)?;
|
pub fn from_password(password: &str, salt: &[u8; 32]) -> Result<Self, CryptoError> {
|
||||||
|
|
||||||
let password_bytes = password.as_bytes();
|
let password_bytes = password.as_bytes();
|
||||||
|
let mut key_material = Vec::with_capacity(password_bytes.len() + salt.len());
|
||||||
|
key_material.extend_from_slice(password_bytes);
|
||||||
|
key_material.extend_from_slice(salt);
|
||||||
|
|
||||||
let derived_key = pwhash::pwhash(
|
let hash = Sha256::digest(&key_material);
|
||||||
password_bytes,
|
let key = Key::<Aes256Gcm>::from_slice(&hash);
|
||||||
salt,
|
|
||||||
pwhash::Oscillating::interactive().unwrap(),
|
|
||||||
secretbox::KEYBYTES,
|
|
||||||
).map_err(|_| CryptoError::KeyDerivationFailed)?;
|
|
||||||
|
|
||||||
key.as_mut_slice().copy_from_slice(&derived_key);
|
Ok(CryptoManager { key: *key })
|
||||||
|
|
||||||
Ok(CryptoManager { key })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
||||||
let nonce = secretbox::gen_nonce();
|
let nonce = Aes256Gcm::generate_nonce(OsRng);
|
||||||
let ciphertext = secretbox::seal(plaintext, &nonce, &self.key)
|
let ciphertext = Aes256Gcm::encrypt(&nonce, plaintext, None)
|
||||||
.map_err(|_| CryptoError::EncryptionFailed)?;
|
.map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
|
|
||||||
Ok((nonce.as_ref().to_vec(), ciphertext))
|
Ok((nonce.to_vec(), ciphertext))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
let nonce = secretbox::Nonce::from_slice(nonce)
|
let nonce = Nonce::from_slice(nonce);
|
||||||
.ok_or(CryptoError::InvalidNonce)?;
|
let plaintext = Aes256Gcm::decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
let plaintext = secretbox::open(ciphertext, &nonce, &self.key)
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
Ok(plaintext)
|
Ok(plaintext)
|
||||||
@@ -81,11 +76,11 @@ impl CryptoManager {
|
|||||||
let combined = STANDARD.decode(encrypted_data)
|
let combined = STANDARD.decode(encrypted_data)
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
if combined.len() < secretbox::NONCEBYTES + secretbox::MACBYTES {
|
if combined.len() < 12 + 16 {
|
||||||
return Err(CryptoError::DecryptionFailed);
|
return Err(CryptoError::DecryptionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce_len = secretbox::NONCEBYTES;
|
let nonce_len = 12;
|
||||||
let nonce = &combined[..nonce_len];
|
let nonce = &combined[..nonce_len];
|
||||||
let ciphertext = &combined[nonce_len..];
|
let ciphertext = &combined[nonce_len..];
|
||||||
|
|
||||||
@@ -96,26 +91,25 @@ impl CryptoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_salt() -> [u8; pwhash::SALTBYTES] {
|
pub fn generate_salt() -> [u8; 32] {
|
||||||
let mut salt = [0u8; pwhash::SALTBYTES];
|
let mut salt = [0u8; 32];
|
||||||
sodiumoxide::init().unwrap();
|
|
||||||
rand::Rng::fill(&mut rand::thread_rng(), &mut salt);
|
rand::Rng::fill(&mut rand::thread_rng(), &mut salt);
|
||||||
salt
|
salt
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn salt_to_base64(salt: &[u8; pwhash::SALTBYTES]) -> String {
|
pub fn salt_to_base64(salt: &[u8; 32]) -> String {
|
||||||
STANDARD.encode(salt)
|
STANDARD.encode(salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn salt_from_base64(salt_str: &str) -> Result<[u8; pwhash::SALTBYTES], CryptoError> {
|
pub fn salt_from_base64(salt_str: &str) -> Result<[u8; 32], CryptoError> {
|
||||||
let decoded = STANDARD.decode(salt_str)
|
let decoded = STANDARD.decode(salt_str)
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
if decoded.len() != pwhash::SALTBYTES {
|
if decoded.len() != 32 {
|
||||||
return Err(CryptoError::DecryptionFailed);
|
return Err(CryptoError::DecryptionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut salt = [0u8; pwhash::SALTBYTES];
|
let mut salt = [0u8; 32];
|
||||||
salt.copy_from_slice(&decoded);
|
salt.copy_from_slice(&decoded);
|
||||||
Ok(salt)
|
Ok(salt)
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/cli.rs
Normal file
134
src/cli.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "api-token-vault")]
|
||||||
|
#[command(author = "API Token Vault")]
|
||||||
|
#[command(version = "0.1.0")]
|
||||||
|
#[command(about = "Secure API token management with encrypted vault storage", long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Commands {
|
||||||
|
#[command(name = "init")]
|
||||||
|
#[command(about = "Initialize a new vault for a project")]
|
||||||
|
Init {
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "generate")]
|
||||||
|
#[command(about = "Generate a new secure API token")]
|
||||||
|
Generate {
|
||||||
|
#[arg(short, long, value_name = "NAME")]
|
||||||
|
name: String,
|
||||||
|
#[arg(short, long, value_name = "LENGTH")]
|
||||||
|
length: Option<usize>,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "list")]
|
||||||
|
#[command(about = "List all tokens in the vault")]
|
||||||
|
List {
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "get")]
|
||||||
|
#[command(about = "Get a specific token value")]
|
||||||
|
Get {
|
||||||
|
#[arg(short, long, value_name = "NAME")]
|
||||||
|
name: String,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
raw: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "delete")]
|
||||||
|
#[command(about = "Delete a token from the vault")]
|
||||||
|
Delete {
|
||||||
|
#[arg(short, long, value_name = "NAME")]
|
||||||
|
name: String,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "rotate")]
|
||||||
|
#[command(about = "Rotate (regenerate) a specific token")]
|
||||||
|
Rotate {
|
||||||
|
#[arg(short, long, value_name = "NAME")]
|
||||||
|
name: String,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "set-rotation")]
|
||||||
|
#[command(about = "Set auto-rotation schedule for a token")]
|
||||||
|
SetRotation {
|
||||||
|
#[arg(short, long, value_name = "NAME")]
|
||||||
|
name: String,
|
||||||
|
#[arg(short, long, value_name = "DAYS")]
|
||||||
|
days: u32,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "inject")]
|
||||||
|
#[command(about = "Inject tokens into a .env file")]
|
||||||
|
Inject {
|
||||||
|
#[arg(short, long, value_name = "FILE")]
|
||||||
|
env_file: Option<String>,
|
||||||
|
#[arg(short, long, value_name = "PREFIX")]
|
||||||
|
token_prefix: Option<String>,
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "check-expired")]
|
||||||
|
#[command(about = "Check for expired tokens")]
|
||||||
|
CheckExpired {
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(name = "rotate-expired")]
|
||||||
|
#[command(about = "Rotate all expired tokens")]
|
||||||
|
RotateExpired {
|
||||||
|
#[arg(short, long, value_name = "PROJECT")]
|
||||||
|
project: Option<String>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
master_password: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
Parser::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/crypto.rs
Normal file
121
src/crypto.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use sodiumoxide::crypto::pwhash;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
KeyDerivationFailed,
|
||||||
|
EncryptionFailed,
|
||||||
|
DecryptionFailed,
|
||||||
|
InvalidKey,
|
||||||
|
InvalidNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CryptoError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CryptoError::KeyDerivationFailed => write!(f, "Failed to derive encryption key from password"),
|
||||||
|
CryptoError::EncryptionFailed => write!(f, "Failed to encrypt data"),
|
||||||
|
CryptoError::DecryptionFailed => write!(f, "Failed to decrypt data"),
|
||||||
|
CryptoError::InvalidKey => write!(f, "Invalid encryption key"),
|
||||||
|
CryptoError::InvalidNonce => write!(f, "Invalid nonce"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CryptoError {}
|
||||||
|
|
||||||
|
pub struct CryptoManager {
|
||||||
|
key: secretbox::Key,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoManager {
|
||||||
|
pub fn from_password(password: &str, salt: &[u8; pwhash::SALTBYTES]) -> Result<Self, CryptoError> {
|
||||||
|
let mut key = secretbox::Key::from_slice(&[0u8; secretbox::KEYBYTES])
|
||||||
|
.ok_or(CryptoError::InvalidKey)?;
|
||||||
|
|
||||||
|
let password_bytes = password.as_bytes();
|
||||||
|
|
||||||
|
let derived_key = pwhash::pwhash(
|
||||||
|
password_bytes,
|
||||||
|
salt,
|
||||||
|
pwhash::Oscillating::interactive().unwrap(),
|
||||||
|
secretbox::KEYBYTES,
|
||||||
|
).map_err(|_| CryptoError::KeyDerivationFailed)?;
|
||||||
|
|
||||||
|
key.as_mut_slice().copy_from_slice(&derived_key);
|
||||||
|
|
||||||
|
Ok(CryptoManager { key })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
||||||
|
let nonce = secretbox::gen_nonce();
|
||||||
|
let ciphertext = secretbox::seal(plaintext, &nonce, &self.key)
|
||||||
|
.map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
|
|
||||||
|
Ok((nonce.as_ref().to_vec(), ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let nonce = secretbox::Nonce::from_slice(nonce)
|
||||||
|
.ok_or(CryptoError::InvalidNonce)?;
|
||||||
|
|
||||||
|
let plaintext = secretbox::open(ciphertext, &nonce, &self.key)
|
||||||
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_base64(&self, plaintext: &str) -> Result<String, CryptoError> {
|
||||||
|
let (nonce, ciphertext) = self.encrypt(plaintext.as_bytes())?;
|
||||||
|
|
||||||
|
let mut combined = Vec::with_capacity(nonce.len() + ciphertext.len());
|
||||||
|
combined.extend_from_slice(&nonce);
|
||||||
|
combined.extend_from_slice(&ciphertext);
|
||||||
|
|
||||||
|
Ok(STANDARD.encode(combined))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_base64(&self, encrypted_data: &str) -> Result<String, CryptoError> {
|
||||||
|
let combined = STANDARD.decode(encrypted_data)
|
||||||
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
if combined.len() < secretbox::NONCEBYTES + secretbox::MACBYTES {
|
||||||
|
return Err(CryptoError::DecryptionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce_len = secretbox::NONCEBYTES;
|
||||||
|
let nonce = &combined[..nonce_len];
|
||||||
|
let ciphertext = &combined[nonce_len..];
|
||||||
|
|
||||||
|
let plaintext = self.decrypt(nonce, ciphertext)?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext)
|
||||||
|
.map_err(|_| CryptoError::DecryptionFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_salt() -> [u8; pwhash::SALTBYTES] {
|
||||||
|
let mut salt = [0u8; pwhash::SALTBYTES];
|
||||||
|
sodiumoxide::init().unwrap();
|
||||||
|
rand::Rng::fill(&mut rand::thread_rng(), &mut salt);
|
||||||
|
salt
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn salt_to_base64(salt: &[u8; pwhash::SALTBYTES]) -> String {
|
||||||
|
STANDARD.encode(salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn salt_from_base64(salt_str: &str) -> Result<[u8; pwhash::SALTBYTES], CryptoError> {
|
||||||
|
let decoded = STANDARD.decode(salt_str)
|
||||||
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
if decoded.len() != pwhash::SALTBYTES {
|
||||||
|
return Err(CryptoError::DecryptionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut salt = [0u8; pwhash::SALTBYTES];
|
||||||
|
salt.copy_from_slice(&decoded);
|
||||||
|
Ok(salt)
|
||||||
|
}
|
||||||
320
src/env_injector.rs
Normal file
320
src/env_injector.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
use crate::vault::Vault;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EnvInjectorError {
|
||||||
|
FileNotFound(String),
|
||||||
|
ReadError(String),
|
||||||
|
WriteError(String),
|
||||||
|
ParseError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for EnvInjectorError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
EnvInjectorError::FileNotFound(path) => write!(f, "File not found: {}", path),
|
||||||
|
EnvInjectorError::ReadError(msg) => write!(f, "Read error: {}", msg),
|
||||||
|
EnvInjectorError::WriteError(msg) => write!(f, "Write error: {}", msg),
|
||||||
|
EnvInjectorError::ParseError(msg) => write!(f, "Parse error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for EnvInjectorError {}
|
||||||
|
|
||||||
|
pub struct EnvEntry {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub line_number: usize,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvEntry {
|
||||||
|
pub fn from_line(line: &str, line_num: usize) -> Option<Self> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(eq_pos) = trimmed.find('=') {
|
||||||
|
let key = trimmed[..eq_pos].trim().to_string();
|
||||||
|
let value = trimmed[eq_pos + 1..].trim().to_string();
|
||||||
|
|
||||||
|
Some(EnvEntry {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
line_number: line_num,
|
||||||
|
comment: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format(&self) -> String {
|
||||||
|
format!("{}={}", self.key, self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inject_tokens(
|
||||||
|
vault: &Vault,
|
||||||
|
env_path: &str,
|
||||||
|
prefix: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<(), EnvInjectorError> {
|
||||||
|
let path = Path::new(env_path);
|
||||||
|
|
||||||
|
let existing_entries = if path.exists() {
|
||||||
|
read_env_file(path)?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = prefix.unwrap_or_else(|| "API_TOKEN_".to_string());
|
||||||
|
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
let mut output_lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let mut existing_keys: Vec<String> = existing_entries.iter()
|
||||||
|
.map(|e| e.key.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = if path.exists() {
|
||||||
|
read_file_lines(path)?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (token_name, token_data) in &vault.tokens {
|
||||||
|
let env_key = format!("{}{}", prefix, token_name.to_uppercase());
|
||||||
|
|
||||||
|
let existing_entry = existing_entries.iter()
|
||||||
|
.find(|e| e.key == env_key);
|
||||||
|
|
||||||
|
match existing_entry {
|
||||||
|
Some(entry) => {
|
||||||
|
let new_line = format!("{}={}", env_key, token_data.value);
|
||||||
|
let line_idx = entry.line_number.saturating_sub(1);
|
||||||
|
|
||||||
|
if line_idx < lines.len() && lines[line_idx] != new_line {
|
||||||
|
changes.push((format!("Updated: {}={}", env_key, mask_value(&token_data.value))));
|
||||||
|
if !dry_run {
|
||||||
|
lines[line_idx] = new_line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
changes.push((format!("Added: {}={}", env_key, mask_value(&token_data.value))));
|
||||||
|
if !dry_run {
|
||||||
|
lines.push(format!("{}={}", env_key, token_data.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
if !changes.is_empty() {
|
||||||
|
println!("Dry run - would make {} changes:", changes.len());
|
||||||
|
for change in &changes {
|
||||||
|
println!(" {}", change);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No changes needed.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let content = lines.join("\n");
|
||||||
|
write_env_file(path, &content)?;
|
||||||
|
|
||||||
|
if !changes.is_empty() {
|
||||||
|
println!("Applied {} changes:", changes.len());
|
||||||
|
for change in &changes {
|
||||||
|
println!(" {}", change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_value(value: &str) -> String {
|
||||||
|
if value.len() <= 4 {
|
||||||
|
"****".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}****", &value[..4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_env_file(path: &Path) -> Result<Vec<EnvEntry>, EnvInjectorError> {
|
||||||
|
let content = fs::read_to_string(path)
|
||||||
|
.map_err(|e| EnvInjectorError::ReadError(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
if let Some(entry) = EnvEntry::from_line(line, line_num + 1) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_lines(path: &Path) -> Result<Vec<String>, EnvInjectorError> {
|
||||||
|
let content = fs::read_to_string(path)
|
||||||
|
.map_err(|e| EnvInjectorError::ReadError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(content.lines().map(String::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_env_file(path: &Path, content: &str) -> Result<(), EnvInjectorError> {
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.create(true)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
|
||||||
|
|
||||||
|
file.write_all(content.as_bytes())
|
||||||
|
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
|
||||||
|
|
||||||
|
if !content.is_empty() {
|
||||||
|
file.write_all(b"\n")
|
||||||
|
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup_env_file(path: &Path) -> Result<String, EnvInjectorError> {
|
||||||
|
let backup_path = format!("{}.bak", path.display());
|
||||||
|
fs::copy(path, &backup_path)
|
||||||
|
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
|
||||||
|
Ok(backup_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_env_value(path: &Path, key: &str) -> Result<Option<String>, EnvInjectorError> {
|
||||||
|
let entries = read_env_file(path)?;
|
||||||
|
|
||||||
|
Ok(entries.iter()
|
||||||
|
.find(|e| e.key == key)
|
||||||
|
.map(|e| e.value.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::vault::Vault;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn get_test_env_path() -> PathBuf {
|
||||||
|
PathBuf::from("/tmp/test.env")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_entry_parsing() {
|
||||||
|
let entry = EnvEntry::from_line("API_KEY=my_secret_key", 1);
|
||||||
|
assert!(entry.is_some());
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
assert_eq!(entry.key, "API_KEY");
|
||||||
|
assert_eq!(entry.value, "my_secret_key");
|
||||||
|
assert_eq!(entry.line_number, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_and_comment_lines() {
|
||||||
|
assert!(EnvEntry::from_line("", 1).is_none());
|
||||||
|
assert!(EnvEntry::from_line("# This is a comment", 1).is_none());
|
||||||
|
assert!(EnvEntry::from_line(" ", 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_into_new_file() {
|
||||||
|
let password = "test";
|
||||||
|
let project = "test_inject";
|
||||||
|
let env_path = get_test_env_path();
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("my_token", 32).unwrap();
|
||||||
|
vault.set_rotation("my_token", 30).unwrap();
|
||||||
|
|
||||||
|
if env_path.exists() {
|
||||||
|
fs::remove_file(&env_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(env_path.exists());
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&env_path).unwrap();
|
||||||
|
assert!(content.contains("API_TOKEN_MY_TOKEN="));
|
||||||
|
|
||||||
|
fs::remove_file(&env_path).unwrap();
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_into_existing_file() {
|
||||||
|
let password = "test";
|
||||||
|
let project = "test_inject_existing";
|
||||||
|
let env_path = get_test_env_path();
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("new_token", 32).unwrap();
|
||||||
|
vault.set_rotation("new_token", 30).unwrap();
|
||||||
|
|
||||||
|
fs::write(&env_path, "EXISTING_VAR=existing_value\n").unwrap();
|
||||||
|
|
||||||
|
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&env_path).unwrap();
|
||||||
|
assert!(content.contains("EXISTING_VAR=existing_value"));
|
||||||
|
assert!(content.contains("API_TOKEN_NEW_TOKEN="));
|
||||||
|
|
||||||
|
fs::remove_file(&env_path).unwrap();
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dry_run() {
|
||||||
|
let password = "test";
|
||||||
|
let project = "test_dry_run";
|
||||||
|
let env_path = get_test_env_path();
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("dry_token", 32).unwrap();
|
||||||
|
vault.set_rotation("dry_token", 30).unwrap();
|
||||||
|
|
||||||
|
if env_path.exists() {
|
||||||
|
fs::remove_file(&env_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&env_path, "# Existing file").unwrap();
|
||||||
|
let original_content = fs::read_to_string(&env_path).unwrap();
|
||||||
|
|
||||||
|
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, true);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let new_content = fs::read_to_string(&env_path).unwrap();
|
||||||
|
assert_eq!(original_content, new_content);
|
||||||
|
|
||||||
|
fs::remove_file(&env_path).unwrap();
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
375
src/main.rs
Normal file
375
src/main.rs
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
mod cli;
|
||||||
|
mod vault;
|
||||||
|
mod token;
|
||||||
|
mod rotation;
|
||||||
|
mod env_injector;
|
||||||
|
mod crypto;
|
||||||
|
|
||||||
|
use cli::Cli;
|
||||||
|
use vault::Vault;
|
||||||
|
use crypto::CryptoManager;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
cli::Commands::Init { master_password, project } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
"default".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::initialize(&password, &project_name) {
|
||||||
|
Ok(_) => println!("Vault initialized for project '{}'", project_name),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to initialize vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::Generate { name, length, project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let token_length = length.unwrap_or(32);
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(mut vault) => {
|
||||||
|
match vault.generate_token(&name, token_length) {
|
||||||
|
Ok(generated_token) => {
|
||||||
|
match vault.save(&password) {
|
||||||
|
Ok(_) => println!("Generated token '{}': {}", name, generated_token),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to save vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to generate token: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::List { project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(vault) => {
|
||||||
|
if vault.tokens.is_empty() {
|
||||||
|
println!("No tokens found in vault for project '{}'", project_name);
|
||||||
|
} else {
|
||||||
|
println!("Tokens in vault for project '{}':", project_name);
|
||||||
|
println!("{}", "-".repeat(50));
|
||||||
|
for (name, token_data) in &vault.tokens {
|
||||||
|
println!(" Name: {}", name);
|
||||||
|
println!(" Created: {}", token_data.created_at);
|
||||||
|
if let Some(expires) = token_data.expires_at {
|
||||||
|
println!(" Expires: {}", expires);
|
||||||
|
} else {
|
||||||
|
println!(" Expires: Never");
|
||||||
|
}
|
||||||
|
println!(" Rotated: {}", if token_data.auto_rotate { "Yes" } else { "No" });
|
||||||
|
println!("{}", "-".repeat(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::Get { name, project, master_password, raw } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(vault) => {
|
||||||
|
match vault.get_token(&name) {
|
||||||
|
Some(token_data) => {
|
||||||
|
if raw {
|
||||||
|
print!("{}", token_data.value);
|
||||||
|
} else {
|
||||||
|
println!("Token '{}': {}", name, token_data.value);
|
||||||
|
println!("Created: {}", token_data.created_at);
|
||||||
|
if let Some(expires) = &token_data.expires_at {
|
||||||
|
println!("Expires: {}", expires);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!("Token '{}' not found", name);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::Delete { name, project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(mut vault) => {
|
||||||
|
match vault.remove_token(&name) {
|
||||||
|
Ok(_) => {
|
||||||
|
match vault.save(&password) {
|
||||||
|
Ok(_) => println!("Token '{}' deleted", name),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to save vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to delete token: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::Rotate { name, project, master_password, force } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(mut vault) => {
|
||||||
|
match vault.rotate_token(&name, force) {
|
||||||
|
Ok(new_value) => {
|
||||||
|
match vault.save(&password) {
|
||||||
|
Ok(_) => println!("Rotated token '{}': {}", name, new_value),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to save vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to rotate token: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::SetRotation { name, days, project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(mut vault) => {
|
||||||
|
match vault.set_rotation(&name, days) {
|
||||||
|
Ok(_) => {
|
||||||
|
match vault.save(&password) {
|
||||||
|
Ok(_) => println!("Auto-rotation set for '{}': every {} days", name, days),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to save vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to set rotation: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::Inject { env_file, token_prefix, project, master_password, dry_run } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let env_path = env_file.unwrap_or_else(|| {
|
||||||
|
".env".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(vault) => {
|
||||||
|
match env_injector::inject_tokens(&vault, &env_path, token_prefix, dry_run) {
|
||||||
|
Ok(()) => {
|
||||||
|
if dry_run {
|
||||||
|
println!("Dry run complete. No changes made.");
|
||||||
|
} else {
|
||||||
|
println!("Tokens injected into {}", env_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to inject tokens: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::CheckExpired { project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(vault) => {
|
||||||
|
let expired = vault.check_expired_tokens();
|
||||||
|
if expired.is_empty() {
|
||||||
|
println!("No expired tokens found");
|
||||||
|
} else {
|
||||||
|
println!("Expired tokens:");
|
||||||
|
for (name, token_data) in expired {
|
||||||
|
println!(" {} - expired at {}", name, token_data.expires_at.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli::Commands::RotateExpired { project, master_password } => {
|
||||||
|
let password = master_password.unwrap_or_else(|| {
|
||||||
|
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
|
||||||
|
eprintln!("Failed to read password");
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_name = project.unwrap_or_else(|| {
|
||||||
|
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
match Vault::load(&password, &project_name) {
|
||||||
|
Ok(mut vault) => {
|
||||||
|
let rotated = vault.rotate_expired_tokens();
|
||||||
|
if rotated.is_empty() {
|
||||||
|
println!("No expired tokens to rotate");
|
||||||
|
} else {
|
||||||
|
match vault.save(&password) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Rotated {} expired token(s):", rotated.len());
|
||||||
|
for name in rotated {
|
||||||
|
println!(" - {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to save vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load vault: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/rotation.rs
Normal file
215
src/rotation.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
use crate::vault::Vault;
|
||||||
|
use crate::token::TokenData;
|
||||||
|
use chrono::{DateTime, Utc, Duration};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RotationSchedule {
|
||||||
|
pub token_name: String,
|
||||||
|
pub rotation_days: u32,
|
||||||
|
pub last_rotated: Option<DateTime<Utc>>,
|
||||||
|
pub next_rotation: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RotationSchedule {
|
||||||
|
pub fn from_token(token_name: &str, token_data: &TokenData) -> Option<Self> {
|
||||||
|
if !token_data.auto_rotate {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_rotated = token_data.last_rotated.unwrap_or(token_data.created_at);
|
||||||
|
let next_rotation = Some(last_rotated + Duration::days(token_data.rotation_days.unwrap_or(30) as i64));
|
||||||
|
|
||||||
|
Some(RotationSchedule {
|
||||||
|
token_name: token_name.to_string(),
|
||||||
|
rotation_days: token_data.rotation_days.unwrap_or(30),
|
||||||
|
last_rotated: Some(last_rotated),
|
||||||
|
next_rotation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_due(&self) -> bool {
|
||||||
|
if let Some(next) = self.next_rotation {
|
||||||
|
Utc::now() >= next
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn days_until_rotation(&self) -> Option<i64> {
|
||||||
|
self.next_rotation.map(|next| {
|
||||||
|
let diff = next - Utc::now();
|
||||||
|
diff.num_days()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RotationEngine;
|
||||||
|
|
||||||
|
impl RotationEngine {
|
||||||
|
pub fn check_schedules(vault: &Vault) -> Vec<RotationSchedule> {
|
||||||
|
let mut schedules = Vec::new();
|
||||||
|
|
||||||
|
for (name, token_data) in &vault.tokens {
|
||||||
|
if let Some(schedule) = RotationSchedule::from_token(name, token_data) {
|
||||||
|
schedules.push(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_due_rotations(vault: &Vault) -> Vec<RotationSchedule> {
|
||||||
|
Self::check_schedules(vault)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.is_due())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_upcoming_rotations(vault: &Vault, days: i64) -> Vec<RotationSchedule> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let threshold = now + Duration::days(days);
|
||||||
|
|
||||||
|
Self::check_schedules(vault)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| {
|
||||||
|
if let Some(next) = s.next_rotation {
|
||||||
|
next >= now && next <= threshold
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotate_due_tokens(vault: &mut Vault) -> HashMap<String, String> {
|
||||||
|
let due_names: Vec<String> = vault.tokens.iter()
|
||||||
|
.filter(|(_, token)| token.should_rotate())
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
for name in due_names {
|
||||||
|
if let Ok(new_value) = vault.rotate_token(&name, true) {
|
||||||
|
results.insert(name, new_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calculate_next_rotation(token_data: &TokenData) -> Option<DateTime<Utc>> {
|
||||||
|
let base_time = token_data.last_rotated.unwrap_or(token_data.created_at);
|
||||||
|
let days = token_data.rotation_days.unwrap_or(30);
|
||||||
|
Some(base_time + Duration::days(days as i64))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_schedule_report(schedules: &[RotationSchedule]) -> String {
|
||||||
|
if schedules.is_empty() {
|
||||||
|
return "No scheduled rotations".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut report = String::from("Rotation Schedule:\n");
|
||||||
|
report.push_str(&"=".repeat(50));
|
||||||
|
report.push('\n');
|
||||||
|
|
||||||
|
let mut due_count = 0;
|
||||||
|
let mut upcoming_count = 0;
|
||||||
|
|
||||||
|
for schedule in schedules {
|
||||||
|
let status = if schedule.is_due() {
|
||||||
|
due_count += 1;
|
||||||
|
"DUE NOW"
|
||||||
|
} else if let Some(days) = schedule.days_until_rotation() {
|
||||||
|
upcoming_count += 1;
|
||||||
|
format!("in {} days", days)
|
||||||
|
} else {
|
||||||
|
"unknown".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
report.push_str(&format!(
|
||||||
|
" {}: every {} days - {}\n",
|
||||||
|
schedule.token_name,
|
||||||
|
schedule.rotation_days,
|
||||||
|
status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
report.push_str(&"-".repeat(50));
|
||||||
|
report.push('\n');
|
||||||
|
report.push_str(&format!("Due now: {}, Upcoming: {}\n", due_count, upcoming_count));
|
||||||
|
|
||||||
|
report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::vault::Vault;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotation_schedule_creation() {
|
||||||
|
let password = "test";
|
||||||
|
let project = "test_schedule";
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("auto_rotate", 32).unwrap();
|
||||||
|
vault.set_rotation("auto_rotate", 30).unwrap();
|
||||||
|
|
||||||
|
let schedules = RotationEngine::check_schedules(&vault);
|
||||||
|
assert_eq!(schedules.len(), 1);
|
||||||
|
assert_eq!(schedules[0].token_name, "auto_rotate");
|
||||||
|
assert_eq!(schedules[0].rotation_days, 30);
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
std::fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manual_rotation_not_scheduled() {
|
||||||
|
let password = "test";
|
||||||
|
let project = "test_manual";
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("manual", 32).unwrap();
|
||||||
|
|
||||||
|
let schedules = RotationEngine::check_schedules(&vault);
|
||||||
|
assert!(schedules.is_empty());
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
std::fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schedule_report() {
|
||||||
|
let schedules = vec![
|
||||||
|
RotationSchedule {
|
||||||
|
token_name: "token1".to_string(),
|
||||||
|
rotation_days: 30,
|
||||||
|
last_rotated: Some(Utc::now() - Duration::days(35)),
|
||||||
|
next_rotation: Some(Utc::now() - Duration::days(5)),
|
||||||
|
},
|
||||||
|
RotationSchedule {
|
||||||
|
token_name: "token2".to_string(),
|
||||||
|
rotation_days: 60,
|
||||||
|
last_rotated: Some(Utc::now()),
|
||||||
|
next_rotation: Some(Utc::now() + Duration::days(60)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let report = RotationEngine::format_schedule_report(&schedules);
|
||||||
|
assert!(report.contains("token1"));
|
||||||
|
assert!(report.contains("token2"));
|
||||||
|
assert!(report.contains("DUE NOW"));
|
||||||
|
assert!(report.contains("in 60 days"));
|
||||||
|
|
||||||
|
let empty_report = RotationEngine::format_schedule_report(&[]);
|
||||||
|
assert_eq!(empty_report, "No scheduled rotations");
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/token.rs
Normal file
176
src/token.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rand::Rng;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TokenData {
|
||||||
|
pub value: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub auto_rotate: bool,
|
||||||
|
pub rotation_days: Option<u32>,
|
||||||
|
pub last_rotated: Option<DateTime<Utc>>,
|
||||||
|
pub metadata: Option<TokenMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TokenMetadata {
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub service: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenData {
|
||||||
|
pub fn new(value: String, rotation_days: Option<u32>) -> Self {
|
||||||
|
let created_at = Utc::now();
|
||||||
|
let expires_at = rotation_days.map(|days| created_at + chrono::Duration::days(days as i64));
|
||||||
|
|
||||||
|
TokenData {
|
||||||
|
value,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
auto_rotate: rotation_days.is_some(),
|
||||||
|
rotation_days,
|
||||||
|
last_rotated: None,
|
||||||
|
metadata: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
if let Some(expires_at) = self.expires_at {
|
||||||
|
Utc::now() > expires_at
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_rotate(&self) -> bool {
|
||||||
|
self.auto_rotate && self.is_expired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TokenGenerator;
|
||||||
|
|
||||||
|
impl TokenGenerator {
|
||||||
|
const CHARSET_ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
const CHARSET_ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const CHARSET_BASE64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
pub fn generate(length: usize, charset: TokenCharset) -> String {
|
||||||
|
let charset_bytes = match charset {
|
||||||
|
TokenCharset::Alpha => Self::CHARSET_ALPHA,
|
||||||
|
TokenCharset::Alphanumeric => Self::CHARSET_ALPHANUMERIC,
|
||||||
|
TokenCharset::Base64 => Self::CHARSET_BASE64,
|
||||||
|
TokenCharset::Hex => b"0123456789abcdef",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let chars_len = charset_bytes.len();
|
||||||
|
|
||||||
|
(0..length)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..chars_len);
|
||||||
|
charset_bytes[idx] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_secure(length: usize) -> String {
|
||||||
|
let mut bytes = vec![0u8; length];
|
||||||
|
rand::Rng::fill(&mut rand::thread_rng(), &mut bytes);
|
||||||
|
|
||||||
|
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_uuid() -> String {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_api_key(prefix: &str, length: usize) -> String {
|
||||||
|
let token = Self::generate_secure(length);
|
||||||
|
format!("{}_{}", prefix, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_hex(length: usize) -> String {
|
||||||
|
Self::generate(length, TokenCharset::Hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum TokenCharset {
|
||||||
|
Alpha,
|
||||||
|
Alphanumeric,
|
||||||
|
Base64,
|
||||||
|
Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_token_value(length: Option<usize>, include_special: bool) -> String {
|
||||||
|
let length = length.unwrap_or(32);
|
||||||
|
|
||||||
|
if include_special {
|
||||||
|
generate_secure_token_with_special(length)
|
||||||
|
} else {
|
||||||
|
TokenGenerator::generate_secure(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_secure_token_with_special(length: usize) -> String {
|
||||||
|
let special_chars = b"!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||||
|
let base_chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let mut result = String::with_capacity(length);
|
||||||
|
|
||||||
|
for i in 0..length {
|
||||||
|
if i % 4 == 3 && length - i > 2 {
|
||||||
|
let idx = rng.gen_range(0..special_chars.len());
|
||||||
|
result.push(special_chars[idx] as char);
|
||||||
|
} else {
|
||||||
|
let idx = rng.gen_range(0..base_chars.len());
|
||||||
|
result.push(base_chars[idx] as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_generation() {
|
||||||
|
let token = TokenGenerator::generate(32, TokenCharset::Alphanumeric);
|
||||||
|
assert_eq!(token.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_secure_token_generation() {
|
||||||
|
let token = TokenGenerator::generate_secure(32);
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
assert!(token.len() >= 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_generation() {
|
||||||
|
let uuid1 = TokenGenerator::generate_uuid();
|
||||||
|
let uuid2 = TokenGenerator::generate_uuid();
|
||||||
|
assert_ne!(uuid1, uuid2);
|
||||||
|
assert!(uuid1.parse::<uuid::Uuid>().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_data_expiration() {
|
||||||
|
let token_data = TokenData::new("test".to_string(), Some(30));
|
||||||
|
assert!(!token_data.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_hex() {
|
||||||
|
let hex = TokenGenerator::generate_hex(32);
|
||||||
|
assert_eq!(hex.len(), 32);
|
||||||
|
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
}
|
||||||
338
src/vault.rs
Normal file
338
src/vault.rs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
use crate::token::{TokenData, TokenGenerator, generate_token_value};
|
||||||
|
use crate::crypto::{CryptoManager, CryptoError, generate_salt, salt_to_base64, salt_from_base64};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use dirs;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Vault {
|
||||||
|
pub project_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub salt: String,
|
||||||
|
pub tokens: HashMap<String, TokenData>,
|
||||||
|
version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum VaultError {
|
||||||
|
NotInitialized,
|
||||||
|
InvalidPassword,
|
||||||
|
CorruptedData,
|
||||||
|
FileNotFound(String),
|
||||||
|
IoError(String),
|
||||||
|
TokenNotFound(String),
|
||||||
|
TokenAlreadyExists(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for VaultError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
VaultError::NotInitialized => write!(f, "Vault not initialized. Run 'init' first."),
|
||||||
|
VaultError::InvalidPassword => write!(f, "Invalid master password"),
|
||||||
|
VaultError::CorruptedData => write!(f, "Vault file is corrupted"),
|
||||||
|
VaultError::FileNotFound(path) => write!(f, "Vault file not found: {}", path),
|
||||||
|
VaultError::IoError(msg) => write!(f, "IO error: {}", msg),
|
||||||
|
VaultError::TokenNotFound(name) => write!(f, "Token not found: {}", name),
|
||||||
|
VaultError::TokenAlreadyExists(name) => write!(f, "Token already exists: {}", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for VaultError {}
|
||||||
|
|
||||||
|
impl Vault {
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
|
||||||
|
pub fn initialize(password: &str, project_name: &str) -> Result<Self, VaultError> {
|
||||||
|
let salt = generate_salt();
|
||||||
|
let salt_b64 = salt_to_base64(&salt);
|
||||||
|
|
||||||
|
let crypto = CryptoManager::from_password(password, &salt)
|
||||||
|
.map_err(|_| VaultError::InvalidPassword)?;
|
||||||
|
|
||||||
|
let vault = Vault {
|
||||||
|
project_name: project_name.to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
salt: salt_b64,
|
||||||
|
tokens: HashMap::new(),
|
||||||
|
version: Self::VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
vault.save_to_file(password)?;
|
||||||
|
|
||||||
|
Ok(vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(password: &str, project_name: &str) -> Result<Self, VaultError> {
|
||||||
|
let vault_path = Self::get_vault_path(project_name)?;
|
||||||
|
|
||||||
|
if !vault_path.exists() {
|
||||||
|
return Err(VaultError::NotInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&vault_path)
|
||||||
|
.map_err(|e| VaultError::IoError(e.to_string()))?;
|
||||||
|
|
||||||
|
let encrypted_data: EncryptedVaultData = serde_json::from_str(&content)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
let salt = salt_from_base64(&encrypted_data.salt)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
let crypto = CryptoManager::from_password(password, &salt)
|
||||||
|
.map_err(|_| VaultError::InvalidPassword)?;
|
||||||
|
|
||||||
|
let json_data = crypto.decrypt_base64(&encrypted_data.encrypted_data)
|
||||||
|
.map_err(|_| VaultError::InvalidPassword)?;
|
||||||
|
|
||||||
|
let mut vault: Vault = serde_json::from_str(&json_data)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
vault.updated_at = Utc::now();
|
||||||
|
|
||||||
|
Ok(vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&mut self, password: &str) -> Result<(), VaultError> {
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
self.save_to_file(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_file(&self, password: &str) -> Result<(), VaultError> {
|
||||||
|
let salt = salt_from_base64(&self.salt)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
let crypto = CryptoManager::from_password(password, &salt)
|
||||||
|
.map_err(|_| VaultError::InvalidPassword)?;
|
||||||
|
|
||||||
|
let json_data = serde_json::to_string(self)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
let encrypted_data = crypto.encrypt_base64(&json_data)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
let vault_path = Self::get_vault_path(&self.project_name)?;
|
||||||
|
|
||||||
|
if let Some(parent) = vault_path.parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| VaultError::IoError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted_vault = EncryptedVaultData {
|
||||||
|
version: self.version,
|
||||||
|
salt: self.salt.clone(),
|
||||||
|
encrypted_data,
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = serde_json::to_string(&encrypted_vault)
|
||||||
|
.map_err(|_| VaultError::CorruptedData)?;
|
||||||
|
|
||||||
|
fs::write(&vault_path, content)
|
||||||
|
.map_err(|e| VaultError::IoError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_vault_path(project_name: &str) -> Result<PathBuf, VaultError> {
|
||||||
|
let config_dir = dirs::config_dir()
|
||||||
|
.ok_or_else(|| VaultError::IoError("Could not find config directory".to_string()))?;
|
||||||
|
|
||||||
|
let vault_dir = config_dir.join("api-token-vault");
|
||||||
|
let vault_file = vault_dir.join(format!("{}.json", project_name));
|
||||||
|
|
||||||
|
Ok(vault_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_token(&mut self, name: &str, length: usize) -> Result<String, VaultError> {
|
||||||
|
if self.tokens.contains_key(name) {
|
||||||
|
return Err(VaultError::TokenAlreadyExists(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = generate_token_value(Some(length), false);
|
||||||
|
let token_data = TokenData::new(value.clone(), None);
|
||||||
|
|
||||||
|
self.tokens.insert(name.to_string(), token_data);
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_token(&self, name: &str) -> Option<&TokenData> {
|
||||||
|
self.tokens.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_token(&mut self, name: &str) -> Result<(), VaultError> {
|
||||||
|
if !self.tokens.contains_key(name) {
|
||||||
|
return Err(VaultError::TokenNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tokens.remove(name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotate_token(&mut self, name: &str, force: bool) -> Result<String, VaultError> {
|
||||||
|
let token_data = self.tokens.get_mut(name)
|
||||||
|
.ok_or_else(|| VaultError::TokenNotFound(name.to_string()))?;
|
||||||
|
|
||||||
|
if !force && !token_data.should_rotate() {
|
||||||
|
return Err(VaultError::TokenNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_value = generate_token_value(Some(32), false);
|
||||||
|
let rotation_days = token_data.rotation_days;
|
||||||
|
|
||||||
|
*token_data = TokenData::new(new_value.clone(), rotation_days);
|
||||||
|
token_data.last_rotated = Some(Utc::now());
|
||||||
|
|
||||||
|
Ok(new_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_rotation(&mut self, name: &str, days: u32) -> Result<(), VaultError> {
|
||||||
|
let token_data = self.tokens.get_mut(name)
|
||||||
|
.ok_or_else(|| VaultError::TokenNotFound(name.to_string()))?;
|
||||||
|
|
||||||
|
token_data.auto_rotate = true;
|
||||||
|
token_data.rotation_days = Some(days);
|
||||||
|
token_data.expires_at = Some(Utc::now() + chrono::Duration::days(days as i64));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_expired_tokens(&self) -> HashMap<String, &TokenData> {
|
||||||
|
self.tokens.iter()
|
||||||
|
.filter(|(_, token)| token.is_expired())
|
||||||
|
.map(|(k, v)| (k.clone(), v))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotate_expired_tokens(&mut self) -> Vec<String> {
|
||||||
|
let expired: Vec<String> = self.tokens.iter()
|
||||||
|
.filter(|(_, token)| token.should_rotate())
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut rotated = Vec::new();
|
||||||
|
|
||||||
|
for name in &expired {
|
||||||
|
if let Ok(new_value) = self.rotate_token(name, true) {
|
||||||
|
println!("Rotated {}: {}", name, new_value);
|
||||||
|
rotated.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_token(&mut self, name: &str, value: &str, rotation_days: Option<u32>) -> Result<(), VaultError> {
|
||||||
|
if self.tokens.contains_key(name) {
|
||||||
|
return Err(VaultError::TokenAlreadyExists(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_data = TokenData::new(value.to_string(), rotation_days);
|
||||||
|
self.tokens.insert(name.to_string(), token_data);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct EncryptedVaultData {
|
||||||
|
version: u32,
|
||||||
|
salt: String,
|
||||||
|
encrypted_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn get_test_vault_path() -> PathBuf {
|
||||||
|
PathBuf::from("/tmp/test-api-token-vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vault_initialization() {
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test_project";
|
||||||
|
|
||||||
|
let vault = Vault::initialize(password, project);
|
||||||
|
assert!(vault.is_ok());
|
||||||
|
|
||||||
|
let vault = vault.unwrap();
|
||||||
|
assert_eq!(vault.project_name, project);
|
||||||
|
assert!(vault.tokens.is_empty());
|
||||||
|
assert!(!vault.salt.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vault_save_and_load() {
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test_load_project";
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("test_token", 32).unwrap();
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.save_to_file(password).unwrap();
|
||||||
|
|
||||||
|
let loaded_vault = Vault::load(password, project);
|
||||||
|
assert!(loaded_vault.is_ok());
|
||||||
|
|
||||||
|
let loaded_vault = loaded_vault.unwrap();
|
||||||
|
assert_eq!(loaded_vault.tokens.len(), 1);
|
||||||
|
assert!(loaded_vault.tokens.contains_key("test_token"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_password() {
|
||||||
|
let password = "correct_password";
|
||||||
|
let wrong_password = "wrong_password";
|
||||||
|
let project = "test_wrong_password";
|
||||||
|
|
||||||
|
let vault = Vault::initialize(password, project).unwrap();
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
vault.save_to_file(password).unwrap();
|
||||||
|
|
||||||
|
let loaded = Vault::load(wrong_password, project);
|
||||||
|
assert!(loaded.is_err());
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_rotation() {
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test_rotation";
|
||||||
|
|
||||||
|
let mut vault = Vault::initialize(password, project).unwrap();
|
||||||
|
vault.generate_token("rotate_me", 32).unwrap();
|
||||||
|
|
||||||
|
let original_token = vault.get_token("rotate_me").unwrap().value.clone();
|
||||||
|
let new_value = vault.rotate_token("rotate_me", true).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(original_token, new_value);
|
||||||
|
|
||||||
|
let vault_path = Vault::get_vault_path(project).unwrap();
|
||||||
|
if vault_path.exists() {
|
||||||
|
fs::remove_file(&vault_path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
418
tests/integration_tests.rs
Normal file
418
tests/integration_tests.rs
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
use assert_cmd::prelude::*;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn get_test_vault_path() -> PathBuf {
|
||||||
|
PathBuf::from("/tmp/api-token-vault-test-vault.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_test_vault() {
|
||||||
|
let vault_path = get_test_vault_path();
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_help() {
|
||||||
|
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
cmd.arg("--help");
|
||||||
|
cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("API Token Vault"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_version() {
|
||||||
|
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
cmd.arg("--version");
|
||||||
|
cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
cmd.arg("init")
|
||||||
|
.arg("--project")
|
||||||
|
.arg("test-init-project")
|
||||||
|
.env("API_VAULT_PROJECT", "");
|
||||||
|
|
||||||
|
let assert = cmd.assert();
|
||||||
|
|
||||||
|
cleanup_test_vault();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password_123";
|
||||||
|
let project = "test-gen-project";
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-gen-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("test_token")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
gen_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Generated token"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-list-project";
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-list-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut list_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
list_cmd.arg("list")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
list_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Tokens in vault"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotate_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-rotate-project";
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-rotate-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("rotate_me")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = gen_cmd.output();
|
||||||
|
|
||||||
|
let mut rotate_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
rotate_cmd.arg("rotate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("rotate_me")
|
||||||
|
.arg("--force")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
rotate_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Rotated token"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_rotation_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-set-rotation-project";
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-set-rotation-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("auto_rotate_token")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = gen_cmd.output();
|
||||||
|
|
||||||
|
let mut set_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
set_cmd.arg("set-rotation")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("auto_rotate_token")
|
||||||
|
.arg("--days")
|
||||||
|
.arg("30")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
set_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Auto-rotation set"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_expired_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-check-expired-project";
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-check-expired-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("expired_token")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = gen_cmd.output();
|
||||||
|
|
||||||
|
let mut check_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
check_cmd.arg("check-expired")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
check_cmd.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inject_command() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-inject-project";
|
||||||
|
let env_path = PathBuf::from("/tmp/test-inject.env");
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-inject-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
if env_path.exists() {
|
||||||
|
let _ = fs::remove_file(&env_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("injectable_token")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = gen_cmd.output();
|
||||||
|
|
||||||
|
let mut inject_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
inject_cmd.arg("inject")
|
||||||
|
.arg("--env-file")
|
||||||
|
.arg(env_path.to_str().unwrap())
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
inject_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("injected"));
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if env_path.exists() {
|
||||||
|
let _ = fs::remove_file(&env_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dry_run_inject() {
|
||||||
|
cleanup_test_vault();
|
||||||
|
|
||||||
|
let password = "test_password";
|
||||||
|
let project = "test-dry-run-project";
|
||||||
|
let env_path = PathBuf::from("/tmp/test-dry-run.env");
|
||||||
|
|
||||||
|
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
|
||||||
|
let vault_path = vault_dir.join("test-dry-run-project.json");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if !vault_dir.exists() {
|
||||||
|
let _ = fs::create_dir_all(&vault_dir);
|
||||||
|
}
|
||||||
|
if env_path.exists() {
|
||||||
|
let _ = fs::remove_file(&env_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut init_cmd = Command::cargo_bin("token-vault").unwrap();
|
||||||
|
init_cmd.arg("init")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = init_cmd.output();
|
||||||
|
|
||||||
|
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
gen_cmd.arg("generate")
|
||||||
|
.arg("--name")
|
||||||
|
.arg("dry_token")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
let _ = gen_cmd.output();
|
||||||
|
|
||||||
|
fs::write(&env_path, "EXISTING=value").unwrap();
|
||||||
|
|
||||||
|
let mut inject_cmd = Command::cargo_bin("api-token-vault").unwrap();
|
||||||
|
inject_cmd.arg("inject")
|
||||||
|
.arg("--env-file")
|
||||||
|
.arg(env_path.to_str().unwrap())
|
||||||
|
.arg("--dry-run")
|
||||||
|
.arg("--master-password")
|
||||||
|
.arg(password)
|
||||||
|
.arg("--project")
|
||||||
|
.arg(project);
|
||||||
|
|
||||||
|
inject_cmd.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Dry run"));
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&env_path).unwrap();
|
||||||
|
assert_eq!(content.trim(), "EXISTING=value");
|
||||||
|
|
||||||
|
if vault_path.exists() {
|
||||||
|
let _ = fs::remove_file(&vault_path);
|
||||||
|
}
|
||||||
|
if env_path.exists() {
|
||||||
|
let _ = fs::remove_file(&env_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user