Add backend API for projects & snapshots

Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
Aryan Singh committed 2026-03-14 18:30:10 -05:00
1 parent 3d72c53262
commit 473e8e01fa
57 files changed
+3888 -281

No files matched your search

+122 -2
View File
@@ -67,6 +67,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -107,12 +119,36 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -184,6 +220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
@@ -198,6 +235,18 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
@@ -279,6 +328,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -302,6 +360,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
@@ -325,6 +393,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -419,6 +498,8 @@ name = "envsync-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"base64",
"chrono",
"clap",
"comfy-table",
@@ -534,6 +615,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1137,6 +1228,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1282,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -1292,9 +1394,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2016,6 +2124,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2082,6 +2196,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
+3 -1
View File
@@ -5,7 +5,9 @@ edition = "2024"
[dependencies]
anyhow = "1.0.102"
clap = "4.5.60"
clap = { version = "4.5.60", features = ["derive", "env"] }
argon2 = "0.5"
base64 = "0.22"
comfy-table = "7.2.2"
console = "0.16.2"
ctrlc = "3.5.2"
+54 -26
View File
@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -7,7 +7,6 @@ use tokio::time::sleep;
#[derive(Debug, Serialize)]
struct DeviceAuthRequest {
client_id: String,
client_secret: String,
}
#[derive(Debug, Deserialize)]
@@ -20,59 +19,88 @@ struct DeviceAuthResponse {
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: u64,
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> {
// 1. Request device code
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url);
/// Exchange a refresh token for a new access token.
pub async fn refresh_token(
client: &Client,
keycloak_url: &str,
client_id: &str,
refresh_token: &str,
) -> Result<TokenResponse> {
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
let params = [
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
];
let resp = client.post(&token_url).form(&params).send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
Ok(serde_json::from_str(&text)
.map_err(|e| anyhow!("Failed to parse refresh response: {e}"))?)
} else {
Err(anyhow!("Token refresh failed ({status}): {text}"))
}
}
pub async fn authenticate(
client: &Client,
keycloak_url: &str,
client_id: &str,
) -> Result<TokenResponse> {
let device_auth_url = format!("{keycloak_url}/protocol/openid-connect/auth/device");
let params = [("client_id", client_id)];
let response = client.post(&device_auth_url).form(&params).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Device auth failed ({}): {}", status, body);
anyhow::bail!("Device auth failed ({status}): {body}");
}
let resp: DeviceAuthResponse = response.json().await?;
println!("Open this URL in your browser: {}", resp.verification_uri);
println!("Open this URL in your browser:");
println!(" {}", resp.verification_uri);
println!("Enter the code: {}", resp.user_code);
// 2. Poll for token
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url);
let interval = Duration::from_secs(resp.interval);
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
let interval = Duration::from_secs(resp.interval.max(5));
let start = std::time::Instant::now();
while start.elapsed().as_secs() < resp.expires_in {
loop {
if start.elapsed().as_secs() >= resp.expires_in {
anyhow::bail!("Authentication timed out. Please try again.");
}
sleep(interval).await;
let params = [
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &resp.device_code),
("client_id", client_id),
];
let token_resp = client.post(&token_url).form(&params).send().await?;
let status = token_resp.status();
let text = token_resp.text().await.unwrap_or_default();
println!("Token response status: {}, body: {}", status, text);
if status.is_success() {
let token: TokenResponse = serde_json::from_str(&text)?;
return Ok(token.access_token);
} else if status.as_u16() == 400 && text.contains("authorization_pending") {
sleep(interval).await;
return Ok(token);
} else if status.as_u16() == 400
&& (text.contains("authorization_pending") || text.contains("slow_down"))
{
continue;
} else {
anyhow::bail!("Unexpected token response: {} - {}", status, text);
anyhow::bail!("Token request failed ({status}): {text}");
}
}
anyhow::bail!("Authentication timed out");
}
}
+1
View File
@@ -1 +1,2 @@
pub mod auth;
pub mod vault;
+190
View File
@@ -0,0 +1,190 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::error::EnvSyncError;
// ── Request types ──────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CreateProjectRequest {
pub name: String,
pub environment: String,
}
#[derive(Serialize)]
pub struct PushSnapshotRequest {
pub ciphertext: String,
pub nonce: String,
pub message: Option<String>,
}
#[derive(Serialize)]
struct RegisterPubkeyRequest {
public_key: String,
}
// ── Response types ─────────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
pub struct ProjectResponse {
pub id: String,
pub name: String,
pub environment: String,
pub created_at: String,
}
#[derive(Deserialize, Debug)]
pub struct SnapshotMetaResponse {
pub id: String,
pub version: u32,
pub message: Option<String>,
pub created_at: String,
}
#[derive(Deserialize, Debug)]
pub struct SnapshotFullResponse {
pub id: String,
pub version: u32,
pub ciphertext: String,
pub nonce: String,
pub message: Option<String>,
pub created_at: String,
}
// ── Client ─────────────────────────────────────────────────────────────────
pub struct VaultClient {
client: Client,
api_url: String,
token: String,
}
impl VaultClient {
pub fn new(api_url: impl Into<String>, token: impl Into<String>) -> Self {
Self {
client: Client::new(),
api_url: api_url.into(),
token: token.into(),
}
}
async fn check(&self, resp: reqwest::Response) -> Result<reqwest::Response, EnvSyncError> {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(EnvSyncError::ApiError { status, message });
}
Ok(resp)
}
pub async fn create_project(
&self,
name: &str,
environment: &str,
) -> Result<ProjectResponse, EnvSyncError> {
let resp = self
.client
.post(format!("{}/api/projects", self.api_url))
.bearer_auth(&self.token)
.json(&CreateProjectRequest {
name: name.to_string(),
environment: environment.to_string(),
})
.send()
.await?;
Ok(self.check(resp).await?.json().await?)
}
pub async fn list_projects(&self) -> Result<Vec<ProjectResponse>, EnvSyncError> {
let resp = self
.client
.get(format!("{}/api/projects", self.api_url))
.bearer_auth(&self.token)
.send()
.await?;
Ok(self.check(resp).await?.json().await?)
}
pub async fn push_snapshot(
&self,
project_id: &str,
req: PushSnapshotRequest,
) -> Result<SnapshotMetaResponse, EnvSyncError> {
let resp = self
.client
.post(format!("{}/api/projects/{project_id}/snapshots", self.api_url))
.bearer_auth(&self.token)
.json(&req)
.send()
.await?;
Ok(self.check(resp).await?.json().await?)
}
pub async fn get_latest_snapshot(
&self,
project_id: &str,
) -> Result<Option<SnapshotFullResponse>, EnvSyncError> {
let resp = self
.client
.get(format!(
"{}/api/projects/{project_id}/snapshots/latest",
self.api_url
))
.bearer_auth(&self.token)
.send()
.await?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
Ok(Some(self.check(resp).await?.json().await?))
}
pub async fn list_snapshots(
&self,
project_id: &str,
limit: u32,
) -> Result<Vec<SnapshotMetaResponse>, EnvSyncError> {
let resp = self
.client
.get(format!(
"{}/api/projects/{project_id}/snapshots?limit={limit}",
self.api_url
))
.bearer_auth(&self.token)
.send()
.await?;
Ok(self.check(resp).await?.json().await?)
}
pub async fn get_snapshot(
&self,
project_id: &str,
snap_id: &str,
) -> Result<SnapshotFullResponse, EnvSyncError> {
let resp = self
.client
.get(format!(
"{}/api/projects/{project_id}/snapshots/{snap_id}",
self.api_url
))
.bearer_auth(&self.token)
.send()
.await?;
Ok(self.check(resp).await?.json().await?)
}
pub async fn register_pubkey(&self, public_key: &str) -> Result<(), EnvSyncError> {
let resp = self
.client
.put(format!("{}/api/users/me/pubkey", self.api_url))
.bearer_auth(&self.token)
.json(&RegisterPubkeyRequest {
public_key: public_key.to_string(),
})
.send()
.await?;
self.check(resp).await?;
Ok(())
}
}
+95
View File
@@ -0,0 +1,95 @@
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "envsync",
about = "Zero-knowledge encrypted environment variable manager",
version
)]
pub struct Cli {
/// API server URL (overrides config, env: ENVSYNC_API_URL)
#[arg(long, env = "ENVSYNC_API_URL", global = true)]
pub api_url: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Authenticate with EnvSync via device code flow
Login,
/// Log out and clear stored credentials
Logout,
/// Initialize a new project in the current directory
Init {
/// Project name (defaults to directory name)
#[arg(long, short)]
name: Option<String>,
/// Environment (e.g. development, staging, production)
#[arg(long, short, default_value = "development")]
env: Option<String>,
},
/// Encrypt and push the local .env to the server
Push {
/// Commit message describing this snapshot
#[arg(long, short)]
message: Option<String>,
/// Force push even when behind remote version
#[arg(long)]
force: bool,
},
/// Decrypt and pull the latest .env snapshot from the server
Pull {
/// Overwrite local .env without prompting
#[arg(long)]
force: bool,
},
/// Show diff between local .env and latest remote snapshot
Diff {
/// Print actual values instead of masking with ***
#[arg(long)]
show_values: bool,
},
/// Show snapshot history for this project
Log {
/// Maximum number of entries to display
#[arg(long, short, default_value = "20")]
limit: u32,
},
/// Set a key in the vault and push a new snapshot
Set {
key: String,
value: String,
/// Commit message
#[arg(long, short)]
message: Option<String>,
},
/// Remove a key from the vault and push a new snapshot
Unset {
key: String,
/// Commit message
#[arg(long, short)]
message: Option<String>,
},
/// Roll back the vault to a specific snapshot version
Rollback {
/// Version number to restore
version: u32,
/// Skip confirmation prompt
#[arg(long, short)]
yes: bool,
},
/// Show current auth status and project configuration
Status,
}
+106
View File
@@ -0,0 +1,106 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use console::style;
use dialoguer::Password;
use sodiumoxide::crypto::secretbox;
use std::collections::BTreeMap;
use crate::api::vault::VaultClient;
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(show_values: bool, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let project = LocalProject::load()?;
// Load local .env
let env_path = std::env::current_dir()?.join(".env");
let local_map: BTreeMap<String, String> = if env_path.exists() {
let mut m = BTreeMap::new();
for item in dotenvy::from_path_iter(&env_path)? {
let (k, v) = item?;
m.insert(k, v);
}
m
} else {
BTreeMap::new()
};
// Fetch remote
let vc = VaultClient::new(effective_api_url, &auth.access_token);
let remote_map = match vc.get_latest_snapshot(&project.project_id).await? {
None => {
println!("No remote snapshot found. All local keys are new:");
for k in local_map.keys() {
let display = if show_values {
local_map[k].as_str().to_string()
} else {
"***".to_string()
};
println!(" {}", style(format!("+{k}={display}")).green());
}
return Ok(());
}
Some(snap) => {
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
let ciphertext = B64.decode(&snap.ciphertext)
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
let nonce_bytes = B64.decode(&snap.nonce)
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
let m: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
m
}
};
let all_keys: std::collections::BTreeSet<&String> =
local_map.keys().chain(remote_map.keys()).collect();
let mut has_diff = false;
for key in all_keys {
match (local_map.get(key), remote_map.get(key)) {
(Some(lv), Some(rv)) if lv == rv => {} // unchanged
(Some(lv), Some(rv)) => {
has_diff = true;
if show_values {
println!(" {}", style(format!("~{key}: {rv} → {lv}")).yellow());
} else {
println!(" {}", style(format!("~{key}")).yellow());
}
}
(Some(lv), None) => {
has_diff = true;
let display = if show_values { lv.as_str() } else { "***" };
println!(" {}", style(format!("+{key}={display}")).green());
}
(None, Some(rv)) => {
has_diff = true;
let display = if show_values { rv.as_str() } else { "***" };
println!(" {}", style(format!("-{key}={display}")).red());
}
(None, None) => {}
}
}
if !has_diff {
println!("No differences — local matches remote.");
}
Ok(())
}
+76 -105
View File
@@ -1,118 +1,89 @@
use anyhow::{Result, Context, anyhow};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use dialoguer::{Confirm, Input};
use std::fs;
use std::path::{Path, PathBuf};
use sodiumoxide::crypto::box__;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuthConfig {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub user_id: String,
pub user_email: String,
}
use crate::api::vault::VaultClient;
use crate::config::{Config, LocalProject};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct KeypairConfig {
pub public_key: String, // base64 encoded
pub private_key: String, // base64 encrypted with a passphrase?
}
pub async fn run(
name: Option<String>,
env: Option<String>,
api_url: &str,
) -> Result<()> {
let config = Config::load()?;
let auth = config.require_auth()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VaultContext {
pub project_id: String,
pub project_name: String,
pub environment: String,
pub vault_key: Option<String>, // base64 encrypted with user's public key
}
// Determine defaults from current directory
let cwd = std::env::current_dir()?;
let default_name = cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("my-project")
.to_string();
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub api_url: String,
pub auth: Option<AuthConfig>,
pub keypair: Option<KeypairConfig>,
pub current_context: Option<VaultContext>,
}
let project_name: String = if let Some(n) = name {
n
} else {
Input::new()
.with_prompt("Project name")
.default(default_name)
.interact_text()?
};
impl Config {
pub fn path() -> PathBuf {
ProjectDirs::from("com", "envsync", "cli")
.expect("Failed to get config directory")
.config_dir()
.join("config.toml")
}
let environment: String = if let Some(e) = env {
e
} else {
Input::new()
.with_prompt("Environment")
.default("development".to_string())
.interact_text()?
};
pub fn load() -> Result<Self> {
let path = Self::path();
if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {:?}", path))?;
Ok(toml::from_str(&content)?)
} else {
Ok(Config::default())
println!("Creating project '{project_name}' ({environment})...");
// Check .envsync doesn't already exist
if LocalProject::path().exists() {
let overwrite = Confirm::new()
.with_prompt("A .envsync file already exists. Re-initialize?")
.default(false)
.interact()?;
if !overwrite {
println!("Aborted.");
return Ok(());
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
let project = vc.create_project(&project_name, &environment).await?;
let local = LocalProject {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment: project.environment.clone(),
latest_version: 0,
};
local.save()?;
// Ensure .envsync is in .gitignore
add_to_gitignore(&cwd)?;
println!("Initialized project '{}' (id: {})", project.name, project.id);
println!(" Environment : {}", project.environment);
println!(" .envsync : written (gitignored)");
Ok(())
}
fn add_to_gitignore(dir: &std::path::Path) -> Result<()> {
let path = dir.join(".gitignore");
if path.exists() {
let content = fs::read_to_string(&path)?;
if !content.lines().any(|l| l.trim() == ".envsync") {
let sep = if content.ends_with('\n') || content.is_empty() { "" } else { "\n" };
fs::write(&path, format!("{content}{sep}.envsync\n"))?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
pub fn get_auth_token(&self) -> Result<String> {
self.auth
.as_ref()
.map(|a| a.access_token.clone())
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync init' first."))
}
pub fn get_current_context(&self) -> Result<&VaultContext> {
self.current_context
.as_ref()
.ok_or_else(|| anyhow!("No project initialized. Run 'envsync init' in your project."))
}
pub fn set_current_context(&mut self, context: VaultContext) {
self.current_context = Some(context);
} else {
fs::write(&path, ".envsync\n")?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ProjectConfig {
pub project_name: String,
pub environment: String,
pub env_file_path: PathBuf,
}
impl ProjectConfig {
pub fn from_current_dir(project_name: Option<String>, environment: Option<String>) -> Result<Self> {
let current_dir = std::env::current_dir()?;
// Look for existing .env file
let env_file_path = current_dir.join(".env");
// If project name not provided, use directory name
let project_name = match project_name {
Some(name) => name,
None => current_dir
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("Could not determine project name from directory"))?
.to_string(),
};
let environment = environment.unwrap_or_else(|| "development".to_string());
Ok(ProjectConfig {
project_name,
environment,
env_file_path,
})
}
}
+47
View File
@@ -0,0 +1,47 @@
use anyhow::Result;
use comfy_table::{Cell, Color, Table};
use crate::api::vault::VaultClient;
use crate::config::{Config, LocalProject};
pub async fn run(limit: u32, api_url: &str) -> Result<()> {
let config = Config::load()?;
let auth = config.require_auth()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let project = LocalProject::load()?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
let snapshots = vc.list_snapshots(&project.project_id, limit).await?;
if snapshots.is_empty() {
println!("No snapshots found for '{}'.", project.project_name);
return Ok(());
}
let mut table = Table::new();
table.set_header(vec!["", "Version", "Date", "Message"]);
for snap in &snapshots {
let current_marker = if snap.version == project.latest_version {
"*"
} else {
""
};
let msg = snap.message.as_deref().unwrap_or("-");
let version_cell = if snap.version == project.latest_version {
Cell::new(snap.version).fg(Color::Green)
} else {
Cell::new(snap.version)
};
table.add_row(vec![
Cell::new(current_marker),
version_cell,
Cell::new(&snap.created_at),
Cell::new(msg),
]);
}
println!("Project: {} ({})", project.project_name, project.environment);
println!("{table}");
Ok(())
}
+78
View File
@@ -0,0 +1,78 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use dialoguer::Password;
use reqwest::Client;
use crate::api::auth;
use crate::api::vault::VaultClient;
use crate::config::{AuthConfig, Config, KeypairConfig};
use crate::crypto;
pub async fn run(api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let mut config = Config::load()?;
let keycloak_url = config.keycloak_url.clone();
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
println!("Authenticating with EnvSync...");
let client = Client::new();
let token_resp = auth::authenticate(&client, &keycloak_url, "envsync-app-cli").await?;
// Decode JWT payload (middle segment, URL-safe base64 without padding)
let parts: Vec<&str> = token_resp.access_token.split('.').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid JWT format received from Keycloak");
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| anyhow!("Failed to decode JWT payload: {e}"))?;
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
let user_id = claims["sub"].as_str().unwrap_or("").to_string();
let user_email = claims["email"].as_str().unwrap_or("").to_string();
let exp = claims["exp"].as_i64().unwrap_or(0);
// Generate keypair on first login
let keypair_config = if config.keypair.is_none() {
println!("Generating a new encryption keypair...");
let passphrase = Password::new()
.with_prompt("Enter a passphrase to protect your private key")
.with_confirmation("Confirm passphrase", "Passphrases do not match")
.interact()?;
let salt = crypto::generate_salt();
let master_key = crypto::derive_key(&passphrase, &salt)?;
let (pub_key, sec_key) = crypto::generate_keypair();
let (ct, nonce) = crypto::encrypt_privkey(&sec_key, &master_key)?;
KeypairConfig {
public_key: B64.encode(pub_key.0),
private_key_ct: B64.encode(&ct),
privkey_nonce: B64.encode(nonce.0),
argon2_salt: B64.encode(salt),
}
} else {
println!("Using existing keypair.");
config.keypair.clone().unwrap()
};
// Register public key with the backend
let vc = VaultClient::new(effective_api_url, &token_resp.access_token);
if let Err(e) = vc.register_pubkey(&keypair_config.public_key).await {
eprintln!("Warning: failed to register public key with server: {e}");
}
config.auth = Some(AuthConfig {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: exp,
user_id: user_id.clone(),
user_email: user_email.clone(),
});
config.keypair = Some(keypair_config);
config.save()?;
println!("Logged in as {user_email} (sub: {user_id})");
Ok(())
}
+26
View File
@@ -0,0 +1,26 @@
use anyhow::Result;
use crate::config::Config;
pub fn run() -> Result<()> {
let mut config = Config::load()?;
if config.auth.is_none() {
println!("Not currently logged in.");
return Ok(());
}
let email = config
.auth
.as_ref()
.map(|a| a.user_email.as_str())
.unwrap_or("unknown")
.to_string();
config.auth = None;
// Keep the keypair — user can re-authenticate without re-generating keys.
config.save()?;
println!("Logged out ({email}).");
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
pub mod diff;
pub mod init;
pub mod log;
pub mod login;
pub mod logout;
pub mod pull;
pub mod push;
pub mod rollback;
pub mod set;
pub mod status;
pub mod unset;
+88
View File
@@ -0,0 +1,88 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use dialoguer::{Confirm, Password};
use sodiumoxide::crypto::secretbox;
use std::collections::BTreeMap;
use crate::api::vault::VaultClient;
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(force: bool, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let mut project = LocalProject::load()?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
let latest = match vc.get_latest_snapshot(&project.project_id).await? {
Some(s) => s,
None => {
println!("No snapshots found for this project yet.");
return Ok(());
}
};
if !force && latest.version <= project.latest_version {
println!("Already up to date (v{}).", project.latest_version);
return Ok(());
}
let env_path = std::env::current_dir()?.join(".env");
if !force && env_path.exists() {
let proceed = Confirm::new()
.with_prompt("This will overwrite your local .env. Continue?")
.default(false)
.interact()?;
if !proceed {
println!("Aborted.");
return Ok(());
}
}
// Derive vault key
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
// Decrypt
let ciphertext = B64.decode(&latest.ciphertext)
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
let nonce_bytes = B64.decode(&latest.nonce)
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
write_env_file(&env_path, &env_map)?;
project.latest_version = latest.version;
project.save()?;
println!("Pulled v{} → .env ({} keys)", latest.version, env_map.len());
Ok(())
}
pub fn write_env_file(
path: &std::path::Path,
map: &BTreeMap<String, String>,
) -> Result<()> {
let content: String = map
.iter()
.map(|(k, v)| format!("{k}={v}\n"))
.collect();
std::fs::write(path, content)?;
Ok(())
}
+86
View File
@@ -0,0 +1,86 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use dialoguer::Password;
use std::collections::BTreeMap;
use std::path::Path;
use crate::api::vault::{PushSnapshotRequest, VaultClient};
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(message: Option<String>, force: bool, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let mut project = LocalProject::load()?;
let env_path = std::env::current_dir()?.join(".env");
if !env_path.exists() {
anyhow::bail!("No .env file found in the current directory.");
}
// Prompt passphrase and derive vault key
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
// Parse .env → BTreeMap (deterministic ordering)
let env_map = parse_env_file(&env_path)?;
let plaintext = serde_json::to_vec(&env_map)?;
// Encrypt
let (ciphertext, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
// Conflict check
if !force {
if let Some(latest) = vc.get_latest_snapshot(&project.project_id).await? {
if latest.version > project.latest_version {
anyhow::bail!(
"Remote is at v{}, you have v{}. Run 'envsync pull' first or use --force.",
latest.version,
project.latest_version
);
}
}
}
let snap = vc
.push_snapshot(
&project.project_id,
PushSnapshotRequest {
ciphertext: B64.encode(&ciphertext),
nonce: B64.encode(nonce.0),
message,
},
)
.await?;
project.latest_version = snap.version;
project.save()?;
println!(
"Pushed v{} to '{}' ({})",
snap.version, project.project_name, project.environment
);
Ok(())
}
pub fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
for item in dotenvy::from_path_iter(path)? {
let (k, v) = item?;
map.insert(k, v);
}
Ok(map)
}
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use dialoguer::{Confirm, Password};
use sodiumoxide::crypto::secretbox;
use std::collections::BTreeMap;
use crate::api::vault::{PushSnapshotRequest, VaultClient};
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(version: u32, yes: bool, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let mut project = LocalProject::load()?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
// Find the snapshot with the target version
let snapshots = vc.list_snapshots(&project.project_id, 1000).await?;
let target_meta = snapshots
.iter()
.find(|s| s.version == version)
.ok_or_else(|| anyhow!("Snapshot v{version} not found."))?;
println!(
"Rolling back to v{} — {} — {}",
target_meta.version,
target_meta.created_at,
target_meta.message.as_deref().unwrap_or("-")
);
if !yes {
let confirmed = Confirm::new()
.with_prompt("This will push a new snapshot with the content from that version. Continue?")
.default(false)
.interact()?;
if !confirmed {
println!("Aborted.");
return Ok(());
}
}
// Fetch full snapshot
let snap = vc.get_snapshot(&project.project_id, &target_meta.id).await?;
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
// Decrypt
let ciphertext = B64.decode(&snap.ciphertext)
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
let nonce_bytes = B64.decode(&snap.nonce)
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
// Re-encrypt with a fresh nonce and push as new snapshot (preserves history)
let (new_ct, new_nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
let new_snap = vc
.push_snapshot(
&project.project_id,
PushSnapshotRequest {
ciphertext: B64.encode(&new_ct),
nonce: B64.encode(new_nonce.0),
message: Some(format!("Rollback to v{version}")),
},
)
.await?;
// Write .env
let env_path = std::env::current_dir()?.join(".env");
crate::commands::pull::write_env_file(&env_path, &env_map)?;
project.latest_version = new_snap.version;
project.save()?;
println!(
"Rolled back to v{version} content → pushed as v{} ({} keys)",
new_snap.version,
env_map.len()
);
Ok(())
}
+74
View File
@@ -0,0 +1,74 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use dialoguer::Password;
use sodiumoxide::crypto::secretbox;
use std::collections::BTreeMap;
use crate::api::vault::{PushSnapshotRequest, VaultClient};
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(key: String, value: String, message: Option<String>, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let mut project = LocalProject::load()?;
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
// Pull current remote state (or start fresh)
let mut env_map: BTreeMap<String, String> =
match vc.get_latest_snapshot(&project.project_id).await? {
Some(snap) => {
let ct = B64.decode(&snap.ciphertext)
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
let nonce_bytes = B64.decode(&snap.nonce)
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
serde_json::from_slice(&plaintext)?
}
None => BTreeMap::new(),
};
env_map.insert(key.clone(), value.clone());
let plaintext = serde_json::to_vec(&env_map)?;
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
let msg = message.unwrap_or_else(|| format!("Set {key}"));
let snap = vc
.push_snapshot(
&project.project_id,
PushSnapshotRequest {
ciphertext: B64.encode(&ct),
nonce: B64.encode(nonce.0),
message: Some(msg),
},
)
.await?;
// Update local .env
let env_path = std::env::current_dir()?.join(".env");
crate::commands::pull::write_env_file(&env_path, &env_map)?;
project.latest_version = snap.version;
project.save()?;
println!("Set {key}={value} → pushed v{}", snap.version);
Ok(())
}
+67
View File
@@ -0,0 +1,67 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use crate::config::{Config, LocalProject};
pub fn run() -> Result<()> {
let config = Config::load()?;
println!("EnvSync Status");
println!("{}", "─".repeat(40));
// Auth status
match &config.auth {
None => {
println!("Auth: not logged in");
println!(" Run 'envsync login' to authenticate.");
}
Some(auth) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let expires_in = auth.expires_at - now;
let expiry_str = if expires_in > 0 {
let dt = DateTime::<Utc>::from_timestamp(auth.expires_at, 0)
.map(|d| d.format("%H:%M:%S UTC").to_string())
.unwrap_or_else(|| "unknown".into());
format!("expires at {dt} ({expires_in}s)")
} else {
"EXPIRED — run 'envsync login'".to_string()
};
println!("Auth: logged in");
println!("User: {}", auth.user_email);
println!("Token: {expiry_str}");
println!("Refresh: {}", if auth.refresh_token.is_some() { "available" } else { "none" });
}
}
println!("{}", "─".repeat(40));
// Keypair status
match &config.keypair {
None => println!("Keypair: not generated"),
Some(kp) => println!("Keypair: {} (X25519)", &kp.public_key[..16]),
}
println!("{}", "─".repeat(40));
// Local project status
match LocalProject::load() {
Err(_) => println!("Project: no .envsync file (run 'envsync init')"),
Ok(proj) => {
println!("Project: {}", proj.project_name);
println!("Env: {}", proj.environment);
println!("Local v: v{}", proj.latest_version);
println!("ID: {}", proj.project_id);
}
}
println!("{}", "─".repeat(40));
println!("API: {}", config.api_url);
println!("Keycloak: {}", config.keycloak_url);
Ok(())
}
+78
View File
@@ -0,0 +1,78 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use dialoguer::Password;
use sodiumoxide::crypto::secretbox;
use std::collections::BTreeMap;
use crate::api::vault::{PushSnapshotRequest, VaultClient};
use crate::config::{Config, LocalProject};
use crate::crypto;
pub async fn run(key: String, message: Option<String>, api_url: &str) -> Result<()> {
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
let config = Config::load()?;
let auth = config.require_auth()?;
let keypair = config.require_keypair()?;
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
let mut project = LocalProject::load()?;
let passphrase = Password::new()
.with_prompt("Passphrase")
.interact()?;
let salt_bytes = B64.decode(&keypair.argon2_salt)
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
let salt: [u8; 32] = salt_bytes
.try_into()
.map_err(|_| anyhow!("Invalid salt length"))?;
let vault_key = crypto::derive_key(&passphrase, &salt)?;
let vc = VaultClient::new(effective_api_url, &auth.access_token);
let mut env_map: BTreeMap<String, String> =
match vc.get_latest_snapshot(&project.project_id).await? {
Some(snap) => {
let ct = B64.decode(&snap.ciphertext)
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
let nonce_bytes = B64.decode(&snap.nonce)
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
serde_json::from_slice(&plaintext)?
}
None => {
anyhow::bail!("No remote snapshot found; nothing to unset.");
}
};
if !env_map.contains_key(&key) {
anyhow::bail!("Key '{key}' not found in the vault.");
}
env_map.remove(&key);
let plaintext = serde_json::to_vec(&env_map)?;
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
let msg = message.unwrap_or_else(|| format!("Unset {key}"));
let snap = vc
.push_snapshot(
&project.project_id,
PushSnapshotRequest {
ciphertext: B64.encode(&ct),
nonce: B64.encode(nonce.0),
message: Some(msg),
},
)
.await?;
// Update local .env
let env_path = std::env::current_dir()?.join(".env");
crate::commands::pull::write_env_file(&env_path, &env_map)?;
project.latest_version = snap.version;
project.save()?;
println!("Unset {key} → pushed v{}", snap.version);
Ok(())
}
+199
View File
@@ -0,0 +1,199 @@
use anyhow::{anyhow, Context, Result};
use base64::Engine as _;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn default_api_url() -> String {
"http://localhost:8081".to_string()
}
fn default_keycloak_url() -> String {
"http://localhost:8180/realms/envsync".to_string()
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AuthConfig {
pub access_token: String,
pub refresh_token: Option<String>,
/// Unix timestamp (seconds since epoch)
pub expires_at: i64,
pub user_id: String,
pub user_email: String,
}
/// X25519 keypair — private key is encrypted with the Argon2id-derived master key.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct KeypairConfig {
/// base64 X25519 public key (32 bytes)
pub public_key: String,
/// base64 encrypted private key ciphertext
pub private_key_ct: String,
/// base64 nonce used to encrypt the private key (24 bytes)
pub privkey_nonce: String,
/// base64 Argon2id salt (32 bytes)
pub argon2_salt: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
#[serde(default = "default_api_url")]
pub api_url: String,
#[serde(default = "default_keycloak_url")]
pub keycloak_url: String,
pub auth: Option<AuthConfig>,
pub keypair: Option<KeypairConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
api_url: default_api_url(),
keycloak_url: default_keycloak_url(),
auth: None,
keypair: None,
}
}
}
impl Config {
pub fn path() -> PathBuf {
ProjectDirs::from("com", "envsync", "cli")
.expect("Failed to get config directory")
.config_dir()
.join("config.toml")
}
pub fn load() -> Result<Self> {
let path = Self::path();
if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?;
Ok(toml::from_str(&content)?)
} else {
Ok(Config::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
pub fn require_auth(&self) -> Result<&AuthConfig> {
self.auth
.as_ref()
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))
}
/// Returns a valid AuthConfig, automatically refreshing the access token if it has
/// expired (or will expire within 60 seconds) and a refresh token is available.
pub async fn require_valid_auth(&mut self) -> Result<&AuthConfig> {
let auth = self
.auth
.as_ref()
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
// Token still valid (with 60-second buffer)
if auth.expires_at > now + 60 {
return Ok(self.auth.as_ref().unwrap());
}
// Attempt refresh if we have a refresh token
let refresh_tok = match auth.refresh_token.clone() {
Some(rt) if !rt.is_empty() => rt,
_ => {
anyhow::bail!(
"Access token expired. Run 'envsync login' to re-authenticate."
);
}
};
let client = reqwest::Client::new();
let keycloak_url = self.keycloak_url.clone();
let new_tokens =
crate::api::auth::refresh_token(&client, &keycloak_url, "envsync-app-cli", &refresh_tok)
.await
.map_err(|e| anyhow!("Session expired. Run 'envsync login' to re-authenticate. ({e})"))?;
// Decode new expiry from JWT
let exp = decode_jwt_exp(&new_tokens.access_token).unwrap_or(now + new_tokens.expires_in as i64);
let existing = self.auth.as_ref().unwrap();
self.auth = Some(AuthConfig {
access_token: new_tokens.access_token,
refresh_token: new_tokens.refresh_token.or_else(|| existing.refresh_token.clone()),
expires_at: exp,
user_id: existing.user_id.clone(),
user_email: existing.user_email.clone(),
});
self.save()?;
Ok(self.auth.as_ref().unwrap())
}
pub fn require_keypair(&self) -> Result<&KeypairConfig> {
self.keypair
.as_ref()
.ok_or_else(|| anyhow!("No keypair found. Run 'envsync login' first."))
}
}
fn decode_jwt_exp(token: &str) -> Option<i64> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
}
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
claims["exp"].as_i64()
}
/// Per-repository state stored in `.envsync` (gitignored).
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct LocalProject {
pub project_id: String,
pub project_name: String,
pub environment: String,
pub latest_version: u32,
}
impl LocalProject {
pub fn path() -> PathBuf {
std::env::current_dir()
.expect("Failed to get current directory")
.join(".envsync")
}
pub fn load() -> Result<Self> {
let path = Self::path();
if !path.exists() {
anyhow::bail!(
"No project found in current directory. Run 'envsync init' first."
);
}
let content = fs::read_to_string(&path)?;
Ok(toml::from_str(&content)?)
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
}
+109
View File
@@ -0,0 +1,109 @@
use argon2::{Algorithm, Argon2, Params, Version};
use sodiumoxide::crypto::{box_, secretbox};
use crate::error::EnvSyncError;
/// Derive a 32-byte vault key from a passphrase using Argon2id.
pub fn derive_key(passphrase: &str, salt: &[u8; 32]) -> Result<[u8; 32], EnvSyncError> {
let params = Params::new(65536, 3, 1, Some(32))
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
Ok(key)
}
/// Generate a random 32-byte Argon2id salt.
pub fn generate_salt() -> [u8; 32] {
let mut salt = [0u8; 32];
sodiumoxide::randombytes::randombytes_into(&mut salt);
salt
}
/// Generate an X25519 keypair.
pub fn generate_keypair() -> (box_::PublicKey, box_::SecretKey) {
box_::gen_keypair()
}
/// Encrypt plaintext with XSalsa20-Poly1305. Returns (ciphertext, nonce).
pub fn encrypt_vault(
plaintext: &[u8],
key: &[u8; 32],
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
let sk = secretbox::Key::from_slice(key)
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
let nonce = secretbox::gen_nonce();
let ciphertext = secretbox::seal(plaintext, &nonce, &sk);
Ok((ciphertext, nonce))
}
/// Decrypt ciphertext produced by `encrypt_vault`.
pub fn decrypt_vault(
ciphertext: &[u8],
nonce: &secretbox::Nonce,
key: &[u8; 32],
) -> Result<Vec<u8>, EnvSyncError> {
let sk = secretbox::Key::from_slice(key)
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
secretbox::open(ciphertext, nonce, &sk)
.map_err(|_| EnvSyncError::CryptoError("Decryption failed — wrong passphrase?".to_string()))
}
/// Encrypt an X25519 private key with the master key.
pub fn encrypt_privkey(
privkey: &box_::SecretKey,
master_key: &[u8; 32],
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
encrypt_vault(&privkey.0, master_key)
}
/// Decrypt an X25519 private key with the master key.
pub fn decrypt_privkey(
ct: &[u8],
nonce: &secretbox::Nonce,
master_key: &[u8; 32],
) -> Result<box_::SecretKey, EnvSyncError> {
let bytes = decrypt_vault(ct, nonce, master_key)?;
box_::SecretKey::from_slice(&bytes)
.ok_or_else(|| EnvSyncError::CryptoError("Invalid private key length".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_vault() {
sodiumoxide::init().unwrap();
let passphrase = "test-passphrase";
let salt = generate_salt();
let key = derive_key(passphrase, &salt).unwrap();
let plaintext = b"FOO=bar\nDB=postgres://localhost/test";
let (ct, nonce) = encrypt_vault(plaintext, &key).unwrap();
let decrypted = decrypt_vault(&ct, &nonce, &key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn wrong_passphrase_fails() {
sodiumoxide::init().unwrap();
let salt = generate_salt();
let key = derive_key("correct", &salt).unwrap();
let (ct, nonce) = encrypt_vault(b"secret", &key).unwrap();
let wrong_key = derive_key("wrong", &salt).unwrap();
assert!(decrypt_vault(&ct, &nonce, &wrong_key).is_err());
}
#[test]
fn roundtrip_privkey() {
sodiumoxide::init().unwrap();
let salt = generate_salt();
let master_key = derive_key("passphrase", &salt).unwrap();
let (_, sec) = generate_keypair();
let (ct, nonce) = encrypt_privkey(&sec, &master_key).unwrap();
let recovered = decrypt_privkey(&ct, &nonce, &master_key).unwrap();
assert_eq!(sec.0, recovered.0);
}
}
+34
View File
@@ -0,0 +1,34 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EnvSyncError {
#[error("Not authenticated. Run 'envsync login' first.")]
NotAuthenticated,
#[error("No project found. Run 'envsync init' in your project directory.")]
NoProject,
#[error("API error ({status}): {message}")]
ApiError { status: u16, message: String },
#[error("Crypto error: {0}")]
CryptoError(String),
#[error("Config error: {0}")]
ConfigError(String),
#[error("Push conflict: remote has newer version. Use --force to override.")]
PushConflict,
#[error("Snapshot version {0} not found.")]
SnapshotNotFound(u32),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
+31 -20
View File
@@ -1,27 +1,38 @@
// main.rs
use anyhow::Result;
use reqwest::Client;
use clap::Parser;
// Import your authenticate function from the module where you defined it
mod api;
use api::auth::authenticate;
mod cli;
mod commands;
mod config;
mod crypto;
mod error;
use cli::{Cli, Commands};
#[tokio::main]
async fn main() -> Result<()> {
let keycloak_url = std::env::var("KEYCLOAK_URL")
.unwrap_or_else(|_| "http://localhost:8180/realms/envsync".to_string());
let client_id = std::env::var("KEYCLOAK_CLIENT_ID")
.unwrap_or_else(|_| "envsync-app-cli".to_string());
async fn main() {
let cli = Cli::parse();
// Create an HTTP client
let client = Client::new();
let api_url = cli.api_url.as_deref().unwrap_or("");
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser
let token = authenticate(&client, &keycloak_url, &client_id).await?;
let result = match cli.command {
Commands::Login => commands::login::run(api_url).await,
Commands::Logout => commands::logout::run(),
Commands::Init { name, env } => commands::init::run(name, env, api_url).await,
Commands::Push { message, force } => commands::push::run(message, force, api_url).await,
Commands::Pull { force } => commands::pull::run(force, api_url).await,
Commands::Diff { show_values } => commands::diff::run(show_values, api_url).await,
Commands::Log { limit } => commands::log::run(limit, api_url).await,
Commands::Set { key, value, message } => {
commands::set::run(key, value, message, api_url).await
}
Commands::Unset { key, message } => commands::unset::run(key, message, api_url).await,
Commands::Rollback { version, yes } => commands::rollback::run(version, yes, api_url).await,
Commands::Status => async { commands::status::run() }.await,
};
println!("Access Token: {}", token);
Ok(())
}
if let Err(e) = result {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
}