Add AES-256-GCM encryption for workspace template env vars (cherry-pick from PR #36)
This commit is contained in:
11
.env.example
11
.env.example
@@ -71,3 +71,14 @@ JWT_TTL_DAYS=30
|
||||
# Optional: Secrets encryption (for stored secrets)
|
||||
# =============================================================================
|
||||
# OPENAGENT_SECRET_PASSPHRASE=change-me
|
||||
|
||||
# =============================================================================
|
||||
# Template Env Vars Encryption
|
||||
# =============================================================================
|
||||
# Static AES-256-GCM key for encrypting env_vars in workspace templates.
|
||||
# Format: 64 hex chars (32 bytes) or base64-encoded 32 bytes.
|
||||
# If not set on first template save, a key will be auto-generated and appended.
|
||||
# Encrypted values are stored as: <encrypted v="1">BASE64(nonce||ciphertext)</encrypted>
|
||||
# Legacy plaintext values remain readable (backward compatible).
|
||||
#
|
||||
# PRIVATE_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
|
||||
@@ -56,6 +56,7 @@ aes-gcm = "0.10"
|
||||
pbkdf2 = "0.12"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
|
||||
# Remote console / file manager
|
||||
base64 = "0.22"
|
||||
|
||||
85
SHARED_TASK_NOTES.md
Normal file
85
SHARED_TASK_NOTES.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Encrypted Env Vars - Implementation Notes
|
||||
|
||||
## Goal
|
||||
Encryption-at-rest for workspace template env vars using `PRIVATE_KEY` in `.env`.
|
||||
|
||||
## Current Status
|
||||
- [x] Design complete
|
||||
- [x] `src/library/env_crypto.rs` implemented with full test coverage (13 tests)
|
||||
- [x] Integration into `get_workspace_template()` (decrypt on load)
|
||||
- [x] Integration into `save_workspace_template()` (encrypt on save)
|
||||
- [x] `.env.example` updated with PRIVATE_KEY documentation
|
||||
- [x] All 43 tests passing
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Encryption Format
|
||||
```
|
||||
<encrypted v="1">BASE64(nonce||ciphertext)</encrypted>
|
||||
```
|
||||
- Version in wrapper allows future format changes
|
||||
- 12-byte random nonce prepended to ciphertext
|
||||
- AES-256-GCM AEAD encryption
|
||||
|
||||
### Key Functions (`src/library/env_crypto.rs`)
|
||||
- `is_encrypted(value)` - Check for wrapper format
|
||||
- `encrypt_value(key, plaintext)` - Returns wrapped encrypted string
|
||||
- `decrypt_value(key, value)` - Passthrough if plaintext, decrypt if wrapped
|
||||
- `encrypt_env_vars()` / `decrypt_env_vars()` - Batch operations for HashMap
|
||||
- `load_private_key_from_env()` - Load from PRIVATE_KEY (hex or base64)
|
||||
- `load_or_create_private_key(path)` - Auto-generate if missing (async)
|
||||
- `generate_private_key()` - Generate 32 random bytes
|
||||
|
||||
### Integration Points
|
||||
- `LibraryStore::get_workspace_template()` - Decrypts after JSON parse
|
||||
- `LibraryStore::save_workspace_template()` - Encrypts before JSON serialize
|
||||
|
||||
### Backward Compatibility
|
||||
- Plaintext values pass through unchanged on decrypt
|
||||
- Warning logged if encrypted values found but no key configured
|
||||
- Warning logged if saving plaintext when no key configured
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### 1. Auto-generate key on startup (Priority)
|
||||
Currently `load_or_create_private_key()` exists but isn't called at startup.
|
||||
Need to integrate into application initialization to auto-generate the key.
|
||||
|
||||
Look at `src/main.rs` or startup code to call:
|
||||
```rust
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
env_crypto::load_or_create_private_key(&env_path).await?;
|
||||
```
|
||||
|
||||
### 2. Key rotation command
|
||||
Implement a CLI command or API endpoint to:
|
||||
1. Load old key from env
|
||||
2. Generate new key
|
||||
3. Re-encrypt all template env vars with new key
|
||||
4. Update .env with new key
|
||||
|
||||
### 3. Integration tests
|
||||
Add tests that actually save/load templates through `LibraryStore` with encryption.
|
||||
Current tests only cover the crypto primitives.
|
||||
|
||||
### 4. Dashboard UI verification
|
||||
Verify the dashboard displays plaintext env vars correctly (no UX regression).
|
||||
API endpoints should return decrypted values transparently.
|
||||
|
||||
## Files Changed
|
||||
- `src/library/env_crypto.rs` (NEW) - Crypto utilities
|
||||
- `src/library/mod.rs` - Module declaration + template load/save integration
|
||||
- `Cargo.toml` - Added `hex = "0.4"` dependency
|
||||
- `.env.example` - Documented PRIVATE_KEY
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
cargo test --lib env_crypto # 13 crypto tests
|
||||
cargo test --lib # All 43 tests
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Key format: 64 hex chars OR base64-encoded 32 bytes
|
||||
- No double-encryption (already-encrypted values pass through)
|
||||
- Different encryptions produce different ciphertext (random nonce)
|
||||
- Existing `src/secrets/crypto.rs` uses passphrase-based PBKDF2 (different use case)
|
||||
415
src/library/env_crypto.rs
Normal file
415
src/library/env_crypto.rs
Normal file
@@ -0,0 +1,415 @@
|
||||
//! Encryption utilities for workspace template environment variables.
|
||||
//!
|
||||
//! Uses AES-256-GCM with a static key stored in PRIVATE_KEY environment variable.
|
||||
//! Encrypted values are wrapped in `<encrypted v="1">BASE64</encrypted>` format
|
||||
//! for autodetection. Plaintext values (no wrapper) are treated as legacy.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use rand::RngCore;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Key length in bytes (256 bits for AES-256)
|
||||
const KEY_LENGTH: usize = 32;
|
||||
|
||||
/// Nonce length in bytes (96 bits for AES-GCM)
|
||||
const NONCE_LENGTH: usize = 12;
|
||||
|
||||
/// Environment variable name for the encryption key
|
||||
pub const PRIVATE_KEY_ENV: &str = "PRIVATE_KEY";
|
||||
|
||||
/// Current encryption format version
|
||||
const ENCRYPTION_VERSION: &str = "1";
|
||||
|
||||
/// Wrapper prefix for encrypted values
|
||||
const ENCRYPTED_PREFIX: &str = "<encrypted v=\"";
|
||||
const ENCRYPTED_SUFFIX: &str = "</encrypted>";
|
||||
|
||||
/// Check if a value is encrypted (has the wrapper format).
|
||||
pub fn is_encrypted(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
trimmed.starts_with(ENCRYPTED_PREFIX) && trimmed.ends_with(ENCRYPTED_SUFFIX)
|
||||
}
|
||||
|
||||
/// Parse an encrypted value, returning (version, base64_payload).
|
||||
fn parse_encrypted(value: &str) -> Option<(&str, &str)> {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.starts_with(ENCRYPTED_PREFIX) || !trimmed.ends_with(ENCRYPTED_SUFFIX) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the closing `">` of the version attribute
|
||||
let after_prefix = &trimmed[ENCRYPTED_PREFIX.len()..];
|
||||
let version_end = after_prefix.find("\">")?;
|
||||
let version = &after_prefix[..version_end];
|
||||
|
||||
// Extract the base64 payload between `">` and `</encrypted>`
|
||||
let payload_start = ENCRYPTED_PREFIX.len() + version_end + 2; // +2 for `">`
|
||||
let payload_end = trimmed.len() - ENCRYPTED_SUFFIX.len();
|
||||
let payload = &trimmed[payload_start..payload_end];
|
||||
|
||||
Some((version, payload))
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext value using AES-256-GCM.
|
||||
/// Returns the value wrapped in `<encrypted v="1">BASE64(nonce||ciphertext)</encrypted>`.
|
||||
pub fn encrypt_value(key: &[u8; KEY_LENGTH], plaintext: &str) -> Result<String> {
|
||||
// Don't double-encrypt
|
||||
if is_encrypted(plaintext) {
|
||||
return Ok(plaintext.to_string());
|
||||
}
|
||||
|
||||
// Generate random nonce
|
||||
let mut nonce_bytes = [0u8; NONCE_LENGTH];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
|
||||
// Create cipher and encrypt
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
// Combine nonce + ciphertext and encode
|
||||
let mut combined = Vec::with_capacity(NONCE_LENGTH + ciphertext.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
|
||||
let encoded = BASE64.encode(&combined);
|
||||
|
||||
Ok(format!(
|
||||
"<encrypted v=\"{}\">{}</encrypted>",
|
||||
ENCRYPTION_VERSION, encoded
|
||||
))
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted value.
|
||||
/// If the value is plaintext (no wrapper), returns it unchanged.
|
||||
pub fn decrypt_value(key: &[u8; KEY_LENGTH], value: &str) -> Result<String> {
|
||||
// Passthrough plaintext values
|
||||
let (version, payload) = match parse_encrypted(value) {
|
||||
Some(parsed) => parsed,
|
||||
None => return Ok(value.to_string()),
|
||||
};
|
||||
|
||||
// Validate version
|
||||
if version != ENCRYPTION_VERSION {
|
||||
return Err(anyhow!(
|
||||
"Unsupported encryption version: {}. Expected: {}",
|
||||
version,
|
||||
ENCRYPTION_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
let combined = BASE64
|
||||
.decode(payload)
|
||||
.context("Failed to decode encrypted value")?;
|
||||
|
||||
if combined.len() < NONCE_LENGTH {
|
||||
return Err(anyhow!("Encrypted value too short"));
|
||||
}
|
||||
|
||||
// Split nonce and ciphertext
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(NONCE_LENGTH);
|
||||
|
||||
// Create cipher and decrypt
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| anyhow!("Decryption failed: invalid key or corrupted data"))?;
|
||||
|
||||
String::from_utf8(plaintext).context("Decrypted value is not valid UTF-8")
|
||||
}
|
||||
|
||||
/// Encrypt all values in an env_vars HashMap.
|
||||
/// Values that are already encrypted are left unchanged.
|
||||
pub fn encrypt_env_vars(
|
||||
key: &[u8; KEY_LENGTH],
|
||||
env_vars: &HashMap<String, String>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut encrypted = HashMap::with_capacity(env_vars.len());
|
||||
for (k, v) in env_vars {
|
||||
encrypted.insert(k.clone(), encrypt_value(key, v)?);
|
||||
}
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
/// Decrypt all values in an env_vars HashMap.
|
||||
/// Plaintext values are passed through unchanged.
|
||||
pub fn decrypt_env_vars(
|
||||
key: &[u8; KEY_LENGTH],
|
||||
env_vars: &HashMap<String, String>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut decrypted = HashMap::with_capacity(env_vars.len());
|
||||
for (k, v) in env_vars {
|
||||
decrypted.insert(k.clone(), decrypt_value(key, v)?);
|
||||
}
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
/// Load the encryption key from environment.
|
||||
/// Returns None if PRIVATE_KEY is not set.
|
||||
pub fn load_private_key_from_env() -> Result<Option<[u8; KEY_LENGTH]>> {
|
||||
let key_str = match std::env::var(PRIVATE_KEY_ENV) {
|
||||
Ok(k) if !k.trim().is_empty() => k,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
parse_key(&key_str)
|
||||
.map(Some)
|
||||
.context("Invalid PRIVATE_KEY format")
|
||||
}
|
||||
|
||||
/// Parse a key from hex or base64 format.
|
||||
fn parse_key(key_str: &str) -> Result<[u8; KEY_LENGTH]> {
|
||||
let trimmed = key_str.trim();
|
||||
|
||||
// Try hex first (64 characters = 32 bytes)
|
||||
if trimmed.len() == KEY_LENGTH * 2 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let bytes = hex::decode(trimmed).context("Invalid hex key")?;
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
key.copy_from_slice(&bytes);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// Try base64
|
||||
let bytes = BASE64
|
||||
.decode(trimmed)
|
||||
.context("Key is neither valid hex nor base64")?;
|
||||
|
||||
if bytes.len() != KEY_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Key must be {} bytes, got {} bytes",
|
||||
KEY_LENGTH,
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
key.copy_from_slice(&bytes);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a new random encryption key.
|
||||
pub fn generate_private_key() -> [u8; KEY_LENGTH] {
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
key
|
||||
}
|
||||
|
||||
/// Load the encryption key from environment, generating one if missing.
|
||||
/// If a key is generated, it will be appended to the .env file at the given path.
|
||||
pub async fn load_or_create_private_key(env_file_path: &Path) -> Result<[u8; KEY_LENGTH]> {
|
||||
// Try to load existing key
|
||||
if let Some(key) = load_private_key_from_env()? {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
let key = generate_private_key();
|
||||
let key_hex = hex::encode(key);
|
||||
|
||||
// Append to .env file
|
||||
let env_line = format!("\n# Auto-generated encryption key for template env vars\n{}={}\n", PRIVATE_KEY_ENV, key_hex);
|
||||
|
||||
// Create or append to .env file
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(env_file_path)
|
||||
.await
|
||||
.context("Failed to open .env file for writing")?;
|
||||
|
||||
file.write_all(env_line.as_bytes())
|
||||
.await
|
||||
.context("Failed to write PRIVATE_KEY to .env file")?;
|
||||
|
||||
// Also set in current process environment
|
||||
std::env::set_var(PRIVATE_KEY_ENV, &key_hex);
|
||||
|
||||
tracing::info!("Generated new PRIVATE_KEY and saved to .env");
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> [u8; KEY_LENGTH] {
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
for (i, byte) in key.iter_mut().enumerate() {
|
||||
*byte = i as u8;
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_encrypted() {
|
||||
assert!(is_encrypted("<encrypted v=\"1\">abc123</encrypted>"));
|
||||
assert!(is_encrypted(" <encrypted v=\"1\">abc123</encrypted> "));
|
||||
assert!(!is_encrypted("plaintext"));
|
||||
assert!(!is_encrypted("<encrypted>missing version</encrypted>"));
|
||||
assert!(!is_encrypted("<encrypted v=\"1\">no closing tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = test_key();
|
||||
let plaintext = "my-secret-api-key-12345";
|
||||
|
||||
let encrypted = encrypt_value(&key, plaintext).unwrap();
|
||||
assert!(is_encrypted(&encrypted));
|
||||
assert!(encrypted.starts_with("<encrypted v=\"1\">"));
|
||||
assert!(encrypted.ends_with("</encrypted>"));
|
||||
|
||||
let decrypted = decrypt_value(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plaintext_passthrough() {
|
||||
let key = test_key();
|
||||
let plaintext = "not-encrypted-value";
|
||||
|
||||
let result = decrypt_value(&key, plaintext).unwrap();
|
||||
assert_eq!(result, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_double_encrypt() {
|
||||
let key = test_key();
|
||||
let plaintext = "secret";
|
||||
|
||||
let encrypted = encrypt_value(&key, plaintext).unwrap();
|
||||
let double_encrypted = encrypt_value(&key, &encrypted).unwrap();
|
||||
|
||||
// Should be the same (no double encryption)
|
||||
assert_eq!(encrypted, double_encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_encryptions_differ() {
|
||||
let key = test_key();
|
||||
let plaintext = "same-data";
|
||||
|
||||
let encrypted1 = encrypt_value(&key, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_value(&key, plaintext).unwrap();
|
||||
|
||||
// Different random nonces should produce different ciphertext
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
|
||||
// But both should decrypt to the same value
|
||||
assert_eq!(decrypt_value(&key, &encrypted1).unwrap(), plaintext);
|
||||
assert_eq!(decrypt_value(&key, &encrypted2).unwrap(), plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key1 = test_key();
|
||||
let mut key2 = test_key();
|
||||
key2[0] = 255; // Different key
|
||||
|
||||
let encrypted = encrypt_value(&key1, "secret").unwrap();
|
||||
let result = decrypt_value(&key2, &encrypted);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_env_vars() {
|
||||
let key = test_key();
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("API_KEY".to_string(), "secret-api-key".to_string());
|
||||
env_vars.insert("DB_PASSWORD".to_string(), "db-pass-123".to_string());
|
||||
|
||||
let encrypted = encrypt_env_vars(&key, &env_vars).unwrap();
|
||||
|
||||
// All values should be encrypted
|
||||
for v in encrypted.values() {
|
||||
assert!(is_encrypted(v));
|
||||
}
|
||||
|
||||
let decrypted = decrypt_env_vars(&key, &encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted.get("API_KEY").unwrap(), "secret-api-key");
|
||||
assert_eq!(decrypted.get("DB_PASSWORD").unwrap(), "db-pass-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_plaintext_encrypted() {
|
||||
let key = test_key();
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert(
|
||||
"ENCRYPTED".to_string(),
|
||||
encrypt_value(&key, "secret").unwrap(),
|
||||
);
|
||||
env_vars.insert("PLAINTEXT".to_string(), "not-encrypted".to_string());
|
||||
|
||||
let decrypted = decrypt_env_vars(&key, &env_vars).unwrap();
|
||||
|
||||
assert_eq!(decrypted.get("ENCRYPTED").unwrap(), "secret");
|
||||
assert_eq!(decrypted.get("PLAINTEXT").unwrap(), "not-encrypted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex() {
|
||||
let hex_key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
let key = parse_key(hex_key).unwrap();
|
||||
|
||||
for (i, byte) in key.iter().enumerate() {
|
||||
assert_eq!(*byte, i as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_base64() {
|
||||
let key_bytes = test_key();
|
||||
let base64_key = BASE64.encode(key_bytes);
|
||||
let parsed = parse_key(&base64_key).unwrap();
|
||||
|
||||
assert_eq!(parsed, key_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_invalid() {
|
||||
// Too short
|
||||
assert!(parse_key("abc").is_err());
|
||||
// Invalid hex
|
||||
assert!(parse_key("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_string() {
|
||||
let key = test_key();
|
||||
|
||||
let encrypted = encrypt_value(&key, "").unwrap();
|
||||
let decrypted = decrypt_value(&key, &encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_content() {
|
||||
let key = test_key();
|
||||
let plaintext = "Hello, 世界! 🎉";
|
||||
|
||||
let encrypted = encrypt_value(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_value(&key, &encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - OpenCode settings (`opencode/oh-my-opencode.json`)
|
||||
//! - OpenAgent config (`openagent/config.json`)
|
||||
|
||||
pub mod env_crypto;
|
||||
mod git;
|
||||
pub mod types;
|
||||
|
||||
@@ -1161,6 +1162,7 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Get a workspace template by name with full content.
|
||||
/// Env vars are decrypted if a PRIVATE_KEY is configured; plaintext values pass through.
|
||||
pub async fn get_workspace_template(&self, name: &str) -> Result<WorkspaceTemplate> {
|
||||
Self::validate_name(name)?;
|
||||
let template_path = self
|
||||
@@ -1179,18 +1181,36 @@ impl LibraryStore {
|
||||
let config: WorkspaceTemplateConfig =
|
||||
serde_json::from_str(&content).context("Failed to parse workspace template file")?;
|
||||
|
||||
// Decrypt env vars if we have a key configured
|
||||
let env_vars = match env_crypto::load_private_key_from_env()? {
|
||||
Some(key) => env_crypto::decrypt_env_vars(&key, &config.env_vars)
|
||||
.context("Failed to decrypt template env vars")?,
|
||||
None => {
|
||||
// No key configured - check if any values are encrypted
|
||||
let has_encrypted = config.env_vars.values().any(|v| env_crypto::is_encrypted(v));
|
||||
if has_encrypted {
|
||||
tracing::warn!(
|
||||
"Template '{}' has encrypted env vars but PRIVATE_KEY is not configured",
|
||||
name
|
||||
);
|
||||
}
|
||||
config.env_vars
|
||||
}
|
||||
};
|
||||
|
||||
Ok(WorkspaceTemplate {
|
||||
name: config.name.unwrap_or_else(|| name.to_string()),
|
||||
description: config.description,
|
||||
path: format!("{}/{}.json", WORKSPACE_TEMPLATE_DIR, name),
|
||||
distro: config.distro,
|
||||
skills: config.skills,
|
||||
env_vars: config.env_vars,
|
||||
env_vars,
|
||||
init_script: config.init_script,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a workspace template.
|
||||
/// Env vars are encrypted if a PRIVATE_KEY is configured.
|
||||
pub async fn save_workspace_template(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -1202,12 +1222,27 @@ impl LibraryStore {
|
||||
|
||||
fs::create_dir_all(&templates_dir).await?;
|
||||
|
||||
// Encrypt env vars if we have a key configured
|
||||
let env_vars = match env_crypto::load_private_key_from_env()? {
|
||||
Some(key) => env_crypto::encrypt_env_vars(&key, &template.env_vars)
|
||||
.context("Failed to encrypt template env vars")?,
|
||||
None => {
|
||||
if !template.env_vars.is_empty() {
|
||||
tracing::warn!(
|
||||
"Saving template '{}' with plaintext env vars (PRIVATE_KEY not configured)",
|
||||
name
|
||||
);
|
||||
}
|
||||
template.env_vars.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let config = WorkspaceTemplateConfig {
|
||||
name: Some(name.to_string()),
|
||||
description: template.description.clone(),
|
||||
distro: template.distro.clone(),
|
||||
skills: template.skills.clone(),
|
||||
env_vars: template.env_vars.clone(),
|
||||
env_vars,
|
||||
init_script: template.init_script.clone(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user