diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5b488ae Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b14f7b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +EnvSync is a self-hosted, zero-knowledge encrypted environment variable management system — think git for `.env` files. It consists of three components: + +- **Backend** — Spring Boot 3 REST API (Java 21) +- **Frontend** — Next.js 15 web dashboard (TypeScript) +- **CLI** — Rust-based command-line tool (`cli/envsync-cli/`) + +## Development Infrastructure + +All dependent services run via Docker Compose: + +```bash +docker compose up -d # PostgreSQL 15, Redis 7, MinIO, Keycloak (port 8180), pgAdmin (port 5050) +docker compose down +``` + +## Commands + +### Backend (Spring Boot + Maven) +```bash +cd backend +./mvnw spring-boot:run # Dev server on port 8081 +./mvnw test # Run tests +./mvnw clean package # Build JAR +``` + +### Frontend (Next.js) +```bash +cd frontend +npm install +npm run dev # Dev server on port 3000 +npm run build +npm run lint +``` + +Copy `frontend/.env.example` to `frontend/.env.local` and fill in Keycloak credentials before running. + +### CLI (Rust) +```bash +cd cli/envsync-cli +cargo build # Debug build +cargo build --release # Release build +cargo test # Run tests +cargo clippy # Lint +cargo fmt # Format +``` + +## Architecture + +### Request Flow +``` +CLI / Browser + → NGINX (TLS termination) + → Spring Boot API (port 8081) + → PostgreSQL (vaults, audit logs) + → Redis (pub/sub for real-time notifications) + → MinIO (encrypted vault snapshots, S3-compatible) + → Keycloak (OIDC/JWT validation, port 8180) +``` + +### Authentication +- **CLI** uses OAuth2 device code flow against Keycloak (`cli/envsync-cli/src/api/auth.rs`) +- **Frontend** uses NextAuth with Keycloak OIDC provider (`frontend/src/lib/auth.ts`) +- **Backend** is a Spring Security OAuth2 resource server — validates JWTs from Keycloak (`backend/src/main/java/.../SecurityConfig.java`) +- Keycloak realm: `envsync` + +### Zero-Knowledge Encryption (CLI) +All encryption is client-side before data reaches the server: +- Key derivation: Argon2id +- Encryption: XChaCha20-Poly1305 (via `sodiumoxide`/libsodium) +- Team sharing: vault key wrapped per-member with asymmetric encryption +- Server only stores ciphertext + +### CLI Commands +Implemented in `cli/envsync-cli/src/commands/`: `init`, `push`, `pull`, `diff`, `log`, `set`, `unset`, `rollback` + +### Backend Config +Key settings in `backend/src/main/resources/application.properties`: +- PostgreSQL: `localhost:5432` (user/pass: `admin`/`admin`) +- Redis: `localhost:6379` +- MinIO: `http://localhost:9000` (user/pass: `minioadmin`/`minioadmin`) +- Keycloak JWKS: `http://localhost:8180/realms/envsync` diff --git a/README.md b/README.md index 2401e46..0176670 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ [![Next.js](https://img.shields.io/badge/Next.js-15-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.x-6DB33F?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) -[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql)](https://www.postgresql.org/) +[![Rust](https://img.shields.io/badge/Rust-CLI-CE422B?style=flat-square&logo=rust)](https://www.rust-lang.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-4169E1?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis)](https://redis.io/) [![MinIO](https://img.shields.io/badge/MinIO-Object_Storage-C72E49?style=flat-square&logo=minio)](https://min.io/) [![Keycloak](https://img.shields.io/badge/Keycloak-IAM-4D9B9B?style=flat-square)](https://www.keycloak.org/) -[![NGINX](https://img.shields.io/badge/NGINX-Reverse_Proxy-009639?style=flat-square&logo=nginx)](https://nginx.org/) [![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker)](https://www.docker.com/) > Stop sending `.env` files over Slack. Stop wondering if your teammate has the latest secrets. EnvSync is a CLI + web dashboard that gives your team a single encrypted source of truth for environment variables — with diffing, versioning, and real-time notifications. **100% self-hosted. Zero cloud dependencies.** @@ -34,14 +34,15 @@ Environment variable drift causes bugs that are impossible to reproduce, broken ## ✨ Features - **CLI-first workflow** — `envsync push`, `envsync pull`, `envsync diff` — feels like git for your `.env` -- **End-to-end encryption** — secrets encrypted client-side with `libsodium` before ever hitting the server -- **Real-time notifications** — teammates get notified instantly via Redis pub/sub when variables change +- **End-to-end encryption** — secrets encrypted client-side with `libsodium` (XSalsa20-Poly1305) before ever hitting the server +- **Zero-knowledge by design** — server only stores ciphertext; no one can read your secrets without your passphrase +- **Argon2id key derivation** — memory-hard KDF protects against brute force +- **Versioned snapshots** — every push creates an immutable version; rollback to any previous state - **Environment scoping** — separate vaults for `development`, `staging`, `production` -- **Audit log** — full history of who changed what, when, and from where -- **`.env` diff viewer** — visual diff in the web dashboard showing exactly what changed between versions -- **Org & project management** — multi-team support with role-based access control (Admin, Editor, Viewer) -- **MinIO backups** — encrypted vault snapshots stored in self-hosted S3-compatible object storage +- **Web dashboard** — manage projects, browse snapshot history, and copy CLI commands +- **Audit trail** — every push records who pushed it and when - **Keycloak SSO** — enterprise-grade identity: OIDC login, MFA, LDAP, team provisioning +- **100% self-hosted** — Docker Compose one-liner; nothing phones home --- @@ -49,35 +50,30 @@ Environment variable drift causes bugs that are impossible to reproduce, broken ``` ┌─────────────────────────────────────────────────────────┐ -│ CLI Tool │ -│ envsync push / pull / diff │ +│ CLI Tool (Rust) │ +│ envsync push / pull / diff / rollback / log │ +│ XSalsa20-Poly1305 + Argon2id (client-side) │ └────────────────────────┬────────────────────────────────┘ - │ HTTPS (via NGINX) - │ E2E Encrypted Payload + │ HTTPS · E2E Encrypted Payload ┌────────────────────────▼────────────────────────────────┐ -│ NGINX │ -│ Reverse Proxy / TLS termination │ -└──────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────────┐ -│ Spring Boot API │ -│ Auth · Vault CRUD · Audit Logging │ +│ Spring Boot API (port 8081) │ +│ Auth · Vault CRUD · Snapshot versioning │ └──────┬──────────────────┬──────────────────┬────────────┘ │ │ │ ┌──────▼──────┐ ┌───────▼──────┐ ┌──────▼──────┐ │ PostgreSQL │ │ Redis │ │ MinIO │ │ Vaults & │ │ Pub/Sub │ │ Encrypted │ -│ Audit Logs │ │ Notifications│ │ Backups │ +│ Snapshots │ │ (planned) │ │ Backups │ └─────────────┘ └──────────────┘ └─────────────┘ ┌─────────────────────────────────────────────────────────┐ -│ Keycloak │ -│ SSO · OIDC · Role Management · User Provisioning │ +│ Keycloak (port 8180) │ +│ SSO · OIDC · OAuth2 Device Code Flow │ └─────────────────────────────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────┐ -│ Next.js Dashboard │ -│ Web UI · Real-time updates · Diff viewer │ +│ Next.js Dashboard (port 3000) │ +│ Projects · Snapshot history · CLI guide │ └─────────────────────────────────────────────────────────┘ ``` @@ -85,19 +81,17 @@ Environment variable drift causes bugs that are impossible to reproduce, broken ## 🛠️ Tech Stack -| Layer | Technology | Self-Hosted Replaces | +| Layer | Technology | Version | |---|---|---| -| **Frontend** | Next.js 15 (App Router) | — | -| **Backend** | Spring Boot 3 (Java) | — | -| **Database** | PostgreSQL 16 | AWS RDS | -| **Cache / Pub-Sub** | Redis 7 | AWS ElastiCache / SQS | -| **Object Storage** | MinIO | AWS S3 | -| **Identity / Auth** | Keycloak (OIDC) | AWS IAM / Cognito | -| **Reverse Proxy** | NGINX | AWS CloudFront | -| **Encryption** | libsodium / TweetNaCl.js | AWS KMS | -| **ORM** | Hibernate / Spring Data JPA | — | -| **CLI** | Node.js (npm package) | — | -| **Containerization** | Docker Compose | AWS ECS / Fargate | +| **Frontend** | Next.js (App Router) | 15/16 | +| **Backend** | Spring Boot | 3.x / Java 21 | +| **CLI** | Rust | 2024 Edition | +| **Database** | PostgreSQL | 15 | +| **Cache / Pub-Sub** | Redis | 7 | +| **Object Storage** | MinIO | latest | +| **Identity / Auth** | Keycloak (OIDC) | latest | +| **CLI Encryption** | libsodium (sodiumoxide) | XSalsa20-Poly1305 | +| **Key Derivation** | Argon2id | 64 MB, 3 iterations | --- @@ -106,8 +100,9 @@ Environment variable drift causes bugs that are impossible to reproduce, broken ### Prerequisites - Docker & Docker Compose -- Java 21+ -- Node.js 20+ +- Java 21+ (for backend development) +- Node.js 20+ (for frontend development) +- Rust toolchain (for CLI development) ### 1. Clone & Start Infrastructure @@ -115,54 +110,65 @@ Environment variable drift causes bugs that are impossible to reproduce, broken git clone https://github.com/yourusername/envsync.git cd envsync docker compose up -d -# Starts: PostgreSQL, Redis, MinIO, Keycloak, NGINX +# Starts: PostgreSQL (5432), Redis (6379), MinIO (9000/9001), +# Keycloak (8180), pgAdmin (5050) ``` ### 2. Configure Keycloak ``` Open http://localhost:8180 -Admin console → Create realm: "envsync" -Create client: "envsync-api" -Set redirect URIs: http://localhost:3000/* +Admin credentials: admin / admin + +1. Create realm: "envsync" +2. Create client: "envsync-app" + - Client authentication: ON + - Valid redirect URIs: http://localhost:3000/* +3. Create client: "envsync-app-cli" + - OAuth 2.0 Device Authorization Grant: enabled +4. Create a test user in the realm ``` -### 3. Configure MinIO - -``` -Open http://localhost:9001 (MinIO Console) -Create bucket: "envsync-backups" -Generate access key → paste into backend .env -``` - -### 4. Start the Backend +### 3. Start the Backend ```bash cd backend -cp .env.example .env # fill in Keycloak + MinIO credentials ./mvnw spring-boot:run -# API at http://localhost:8080 +# API at http://localhost:8081 ``` -### 5. Start the Frontend +The backend auto-creates tables via Hibernate DDL (`ddl-auto=update`). No migrations needed. + +### 4. Start the Frontend ```bash cd frontend +cp .env.example .env.local # fill in Keycloak client credentials npm install npm run dev # Dashboard at http://localhost:3000 ``` -### 6. Install the CLI +Required environment variables (`.env.local`): ```bash -npm install -g envsync-cli +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET= +KEYCLOAK_CLIENT_ID=envsync-frontend +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_ISSUER=http://localhost:8180/realms/envsync +NEXT_PUBLIC_API_URL=http://localhost:8081 +``` -envsync login # OIDC flow via Keycloak -envsync init # initialize in your project -envsync push # push your .env to the vault -envsync pull # pull latest secrets -envsync diff # see what changed +### 5. Build & Use the CLI + +```bash +cd cli/envsync-cli +cargo build --release +# Binary at: ./target/release/envsync + +# Or add to PATH: +cargo install --path . ``` --- @@ -170,51 +176,42 @@ envsync diff # see what changed ## 📖 CLI Reference ```bash +# Authentication +envsync login # OAuth2 device code flow via Keycloak +envsync logout # clear stored credentials +envsync status # show auth status, project, token expiry + +# Project initialization (run in your project directory) +envsync init # interactive — prompts for name & env envsync init --project my-app --env development -envsync push # push local .env → vault -envsync pull # pull vault → local .env -envsync diff # diff local vs vault -envsync log # view audit log -envsync set DATABASE_URL "postgres://..." + +# Vault operations +envsync push # encrypt & push local .env → vault +envsync push -m "add stripe webhook" # with a commit message +envsync push --force # skip conflict check +envsync pull # pull latest snapshot → local .env +envsync pull --force # skip confirmation prompt + +# Inspection +envsync diff # diff local .env vs latest remote snapshot +envsync diff --show-values # show actual values (not masked) +envsync log # snapshot history (last 20) +envsync log --limit 50 + +# Mutation helpers +envsync set DATABASE_URL "postgres://..." -m "update db url" envsync unset OLD_API_KEY -envsync envs # list environments (dev/staging/prod) -envsync history # version history of the vault -envsync rollback --version 12 # restore a previous vault version + +# History +envsync rollback --version 12 # restore vault to v12 content (creates new snapshot) +envsync rollback --version 12 --yes # skip confirmation ``` ---- +**Global flags:** -## 📁 Project Structure - -``` -envsync/ -├── frontend/ # Next.js 15 dashboard -│ ├── app/ -│ │ ├── (auth)/ # Keycloak OIDC login flow -│ │ ├── dashboard/ # Overview & notifications -│ │ ├── projects/ # Project vault management -│ │ └── audit/ # Audit log viewer -│ └── components/ -│ ├── DiffViewer/ # .env diff component -│ └── VaultEditor/ # Secret CRUD UI -│ -├── backend/ # Spring Boot API -│ └── src/main/java/ -│ ├── auth/ # Keycloak + Spring Security OIDC -│ ├── vault/ # Vault CRUD & versioning -│ ├── audit/ # Audit log service -│ ├── notification/ # Redis pub/sub notifications -│ └── storage/ # MinIO backup service -│ -├── cli/ # Node.js CLI tool -│ └── src/ -│ ├── commands/ # push, pull, diff, set, unset, rollback -│ └── crypto/ # libsodium client-side encryption -│ -├── nginx/ -│ └── nginx.conf # Reverse proxy + TLS config -│ -└── docker-compose.yml # Full self-hosted stack +```bash +envsync --api-url http://my-server:8081 +# or: export ENVSYNC_API_URL=http://my-server:8081 ``` --- @@ -223,11 +220,87 @@ envsync/ EnvSync uses a **zero-knowledge encryption** model: -1. Secret key derived locally from your password using **Argon2** -2. Secrets encrypted **before** leaving your machine using **XChaCha20-Poly1305** -3. Server only ever stores ciphertext — it physically cannot read your secrets -4. Team sharing via asymmetric encryption — each member's public key wraps the vault key -5. Identity via **Keycloak** — supports MFA, LDAP federation, and social login out of the box +1. **Key derivation** — your passphrase → 32-byte secret key via Argon2id (64 MB, 3 iterations, 1 thread) +2. **Client-side encrypt** — `.env` → JSON → XSalsa20-Poly1305 encryption with a random nonce; ciphertext is base64-encoded +3. **Server stores ciphertext only** — PostgreSQL never sees plaintext; the server physically cannot read your secrets +4. **X25519 keypair** — generated on first login, private key encrypted with your master key; public key registered with the server for future team-sharing features +5. **Identity via Keycloak** — supports MFA, LDAP federation, social login; CLI uses OAuth2 Device Code flow (no browser required) +6. **Token auto-refresh** — CLI automatically refreshes expired access tokens using the stored refresh token + +--- + +## 📁 Project Structure + +``` +envsync/ +├── frontend/ # Next.js dashboard +│ └── src/ +│ ├── app/ +│ │ ├── page.tsx # Landing page +│ │ ├── dashbaord/ # Dashboard (authenticated) +│ │ │ ├── page.tsx # Projects list +│ │ │ ├── layout.tsx # Sidebar navigation +│ │ │ └── projects/[id]/ # Project detail + snapshots +│ │ └── api/auth/ # NextAuth Keycloak handler +│ └── lib/ +│ ├── auth.ts # NextAuth options +│ └── api.ts # Backend API client +│ +├── backend/ # Spring Boot 3 REST API +│ └── src/main/java/com/envsync/backend/ +│ ├── controller/ # ProjectController, SnapshotController, UserController +│ ├── service/ # ProjectService, SnapshotService, UserPubkeyService +│ ├── model/ # JPA entities: Project, Snapshot, UserPubkey +│ ├── repository/ # Spring Data JPA repositories +│ ├── dto/ # Request/Response DTOs +│ ├── SecurityConfig.java # CORS + JWT + OAuth2 Resource Server +│ └── GlobalExceptionHandler.java # Validation + error responses +│ +└── cli/envsync-cli/ # Rust CLI + └── src/ + ├── commands/ # login, logout, init, push, pull, diff, + │ # log, set, unset, rollback, status + ├── api/ # vault.rs (HTTP client), auth.rs (device code + refresh) + ├── crypto.rs # Argon2id + XSalsa20-Poly1305 + X25519 + ├── config.rs # Config persistence + auto token refresh + └── cli.rs # clap CLI definition +``` + +--- + +## 🔌 API Reference + +All endpoints require `Authorization: Bearer `. + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/projects` | Create project | +| `GET` | `/api/projects` | List my projects | +| `GET` | `/api/projects/{id}` | Get project | +| `DELETE` | `/api/projects/{id}` | Delete project | +| `POST` | `/api/projects/{id}/snapshots` | Push snapshot | +| `GET` | `/api/projects/{id}/snapshots` | List snapshots (`?limit=N`) | +| `GET` | `/api/projects/{id}/snapshots/latest` | Get latest snapshot | +| `GET` | `/api/projects/{id}/snapshots/{snapId}` | Get snapshot by ID | +| `PUT` | `/api/users/me/pubkey` | Register X25519 public key | + +--- + +## 🧪 Testing + +```bash +# CLI tests (crypto roundtrips) +cd cli/envsync-cli +cargo test + +# Backend unit tests (no database required) +cd backend +./mvnw test + +# Frontend type-check + build +cd frontend +npm run build +``` --- diff --git a/backend/src/main/java/com/envsync/backend/BackendApplication.java b/backend/src/main/java/com/envsync/backend/BackendApplication.java index e42e644..227b0af 100644 --- a/backend/src/main/java/com/envsync/backend/BackendApplication.java +++ b/backend/src/main/java/com/envsync/backend/BackendApplication.java @@ -8,10 +8,6 @@ public class BackendApplication { public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); - //call the test - - test x = new test(); - } diff --git a/backend/src/main/java/com/envsync/backend/GlobalExceptionHandler.java b/backend/src/main/java/com/envsync/backend/GlobalExceptionHandler.java new file mode 100644 index 0000000..73023a9 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/GlobalExceptionHandler.java @@ -0,0 +1,75 @@ +package com.envsync.backend; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + Map fieldErrors = ex.getBindingResult().getFieldErrors() + .stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value", + (a, b) -> a + )); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", Instant.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", "Validation Failed"); + body.put("fields", fieldErrors); + + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", Instant.now()); + body.put("status", ex.getStatusCode().value()); + body.put("error", ex.getReason() != null ? ex.getReason() : ex.getMessage()); + return ResponseEntity.status(ex.getStatusCode()).body(body); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", Instant.now()); + body.put("status", HttpStatus.FORBIDDEN.value()); + body.put("error", "Access denied"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + logger.error("Unhandled exception", ex); + Map body = new LinkedHashMap<>(); + body.put("timestamp", Instant.now()); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("error", "Internal server error"); + return ResponseEntity.internalServerError().body(body); + } +} diff --git a/backend/src/main/java/com/envsync/backend/SecurityConfig.java b/backend/src/main/java/com/envsync/backend/SecurityConfig.java index 005103b..cbb98af 100644 --- a/backend/src/main/java/com/envsync/backend/SecurityConfig.java +++ b/backend/src/main/java/com/envsync/backend/SecurityConfig.java @@ -1,25 +1,98 @@ package com.envsync.backend; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity public class SecurityConfig { + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + private String jwkSetUri; + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + @Value("${app.security.jwt.audience}") + private String audience; + + @Value("${app.security.jwt.clock-skew-seconds:60}") + private long clockSkewSeconds; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests(auth -> auth - .anyRequest().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(Customizer.withDefaults()) - ); + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // Stateless REST API — no CSRF needed (bearer-token auth, no cookies) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.decoder(jwtDecoder())) + ); return http.build(); } + + @Bean + public JwtDecoder jwtDecoder() { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + + // Timestamp validator with configurable clock skew + JwtTimestampValidator timestampValidator = + new JwtTimestampValidator(java.time.Duration.ofSeconds(clockSkewSeconds)); + + // Issuer validator + OAuth2TokenValidator issuerValidator = + new JwtIssuerValidator(issuerUri); + + // Permissive audience validator — Keycloak may omit aud for some grant types + OAuth2TokenValidator audienceValidator = token -> { + List audiences = token.getAudience(); + if (audiences == null || audiences.isEmpty() || audiences.contains(audience)) { + return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.success(); + } + return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.failure( + new org.springframework.security.oauth2.core.OAuth2Error("invalid_token", + "Token audience does not include: " + audience, null)); + }; + + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + timestampValidator, issuerValidator, audienceValidator)); + return decoder; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of( + "http://localhost:3000", + "http://localhost:3001", + "https://*" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/envsync/backend/controller/ProjectController.java b/backend/src/main/java/com/envsync/backend/controller/ProjectController.java new file mode 100644 index 0000000..30d3775 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/controller/ProjectController.java @@ -0,0 +1,57 @@ +package com.envsync.backend.controller; + +import com.envsync.backend.dto.CreateProjectRequest; +import com.envsync.backend.dto.ProjectResponse; +import com.envsync.backend.model.Project; +import com.envsync.backend.service.ProjectService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/projects") +public class ProjectController { + + private final ProjectService projectService; + + public ProjectController(ProjectService projectService) { + this.projectService = projectService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ProjectResponse create( + @Valid @RequestBody CreateProjectRequest req, + @AuthenticationPrincipal Jwt jwt) { + Project project = projectService.create(req.name(), req.environment(), jwt.getSubject()); + return ProjectResponse.from(project); + } + + @GetMapping + public List list(@AuthenticationPrincipal Jwt jwt) { + return projectService.listForUser(jwt.getSubject()) + .stream() + .map(ProjectResponse::from) + .toList(); + } + + @GetMapping("/{id}") + public ProjectResponse get( + @PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + return ProjectResponse.from(projectService.getForUser(id, jwt.getSubject())); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + projectService.delete(id, jwt.getSubject()); + } +} diff --git a/backend/src/main/java/com/envsync/backend/controller/SnapshotController.java b/backend/src/main/java/com/envsync/backend/controller/SnapshotController.java new file mode 100644 index 0000000..0c446b6 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/controller/SnapshotController.java @@ -0,0 +1,72 @@ +package com.envsync.backend.controller; + +import com.envsync.backend.dto.PushSnapshotRequest; +import com.envsync.backend.dto.SnapshotFullResponse; +import com.envsync.backend.dto.SnapshotMetaResponse; +import com.envsync.backend.model.Project; +import com.envsync.backend.model.Snapshot; +import com.envsync.backend.service.ProjectService; +import com.envsync.backend.service.SnapshotService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/projects/{projectId}/snapshots") +public class SnapshotController { + + private final ProjectService projectService; + private final SnapshotService snapshotService; + + public SnapshotController(ProjectService projectService, SnapshotService snapshotService) { + this.projectService = projectService; + this.snapshotService = snapshotService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public SnapshotMetaResponse push( + @PathVariable UUID projectId, + @Valid @RequestBody PushSnapshotRequest req, + @AuthenticationPrincipal Jwt jwt) { + Project project = projectService.getForUser(projectId, jwt.getSubject()); + Snapshot snap = snapshotService.push( + project, req.ciphertext(), req.nonce(), req.message(), jwt.getSubject()); + return SnapshotMetaResponse.from(snap); + } + + @GetMapping + public List list( + @PathVariable UUID projectId, + @RequestParam(defaultValue = "20") int limit, + @AuthenticationPrincipal Jwt jwt) { + Project project = projectService.getForUser(projectId, jwt.getSubject()); + return snapshotService.list(project, limit) + .stream() + .map(SnapshotMetaResponse::from) + .toList(); + } + + // Note: this must be declared before /{snapId} so Spring MVC matches "latest" as literal + @GetMapping("/latest") + public SnapshotFullResponse getLatest( + @PathVariable UUID projectId, + @AuthenticationPrincipal Jwt jwt) { + Project project = projectService.getForUser(projectId, jwt.getSubject()); + return SnapshotFullResponse.from(snapshotService.getLatest(project)); + } + + @GetMapping("/{snapId}") + public SnapshotFullResponse getById( + @PathVariable UUID projectId, + @PathVariable UUID snapId, + @AuthenticationPrincipal Jwt jwt) { + Project project = projectService.getForUser(projectId, jwt.getSubject()); + return SnapshotFullResponse.from(snapshotService.getById(project, snapId)); + } +} diff --git a/backend/src/main/java/com/envsync/backend/controller/UserController.java b/backend/src/main/java/com/envsync/backend/controller/UserController.java new file mode 100644 index 0000000..5130cfc --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/controller/UserController.java @@ -0,0 +1,28 @@ +package com.envsync.backend.controller; + +import com.envsync.backend.dto.RegisterPubkeyRequest; +import com.envsync.backend.service.UserPubkeyService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/users/me") +public class UserController { + + private final UserPubkeyService userPubkeyService; + + public UserController(UserPubkeyService userPubkeyService) { + this.userPubkeyService = userPubkeyService; + } + + @PutMapping("/pubkey") + public ResponseEntity registerPubkey( + @Valid @RequestBody RegisterPubkeyRequest req, + @AuthenticationPrincipal Jwt jwt) { + userPubkeyService.upsert(jwt.getSubject(), req.public_key()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/envsync/backend/dto/CreateProjectRequest.java b/backend/src/main/java/com/envsync/backend/dto/CreateProjectRequest.java new file mode 100644 index 0000000..222b38e --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/CreateProjectRequest.java @@ -0,0 +1,8 @@ +package com.envsync.backend.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateProjectRequest( + @NotBlank String name, + @NotBlank String environment +) {} diff --git a/backend/src/main/java/com/envsync/backend/dto/ProjectResponse.java b/backend/src/main/java/com/envsync/backend/dto/ProjectResponse.java new file mode 100644 index 0000000..311fe16 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/ProjectResponse.java @@ -0,0 +1,17 @@ +package com.envsync.backend.dto; + +import com.envsync.backend.model.Project; + +import java.time.Instant; +import java.util.UUID; + +public record ProjectResponse( + UUID id, + String name, + String environment, + Instant created_at +) { + public static ProjectResponse from(Project p) { + return new ProjectResponse(p.getId(), p.getName(), p.getEnvironment(), p.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/envsync/backend/dto/PushSnapshotRequest.java b/backend/src/main/java/com/envsync/backend/dto/PushSnapshotRequest.java new file mode 100644 index 0000000..ea8c333 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/PushSnapshotRequest.java @@ -0,0 +1,9 @@ +package com.envsync.backend.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PushSnapshotRequest( + @NotBlank String ciphertext, + @NotBlank String nonce, + String message +) {} diff --git a/backend/src/main/java/com/envsync/backend/dto/RegisterPubkeyRequest.java b/backend/src/main/java/com/envsync/backend/dto/RegisterPubkeyRequest.java new file mode 100644 index 0000000..e84e77d --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/RegisterPubkeyRequest.java @@ -0,0 +1,5 @@ +package com.envsync.backend.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RegisterPubkeyRequest(@NotBlank String public_key) {} diff --git a/backend/src/main/java/com/envsync/backend/dto/SnapshotFullResponse.java b/backend/src/main/java/com/envsync/backend/dto/SnapshotFullResponse.java new file mode 100644 index 0000000..89cc24f --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/SnapshotFullResponse.java @@ -0,0 +1,25 @@ +package com.envsync.backend.dto; + +import com.envsync.backend.model.Snapshot; + +import java.time.Instant; +import java.util.UUID; + +public record SnapshotFullResponse( + UUID id, + int version, + String ciphertext, + String nonce, + String message, + String pushed_by, + Instant created_at +) { + public static SnapshotFullResponse from(Snapshot s) { + return new SnapshotFullResponse( + s.getId(), s.getVersion(), + s.getCiphertext(), s.getNonce(), + s.getMessage(), s.getPushedBy(), + s.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/envsync/backend/dto/SnapshotMetaResponse.java b/backend/src/main/java/com/envsync/backend/dto/SnapshotMetaResponse.java new file mode 100644 index 0000000..bb87567 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/dto/SnapshotMetaResponse.java @@ -0,0 +1,18 @@ +package com.envsync.backend.dto; + +import com.envsync.backend.model.Snapshot; + +import java.time.Instant; +import java.util.UUID; + +public record SnapshotMetaResponse( + UUID id, + int version, + String message, + String pushed_by, + Instant created_at +) { + public static SnapshotMetaResponse from(Snapshot s) { + return new SnapshotMetaResponse(s.getId(), s.getVersion(), s.getMessage(), s.getPushedBy(), s.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/envsync/backend/model/Project.java b/backend/src/main/java/com/envsync/backend/model/Project.java new file mode 100644 index 0000000..a30841e --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/model/Project.java @@ -0,0 +1,47 @@ +package com.envsync.backend.model; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "projects") +public class Project { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String environment; + + /** Keycloak `sub` claim of the project owner. */ + @Column(name = "owner_id", nullable = false) + private String ownerId; + + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + } + + // ── Getters / Setters ────────────────────────────────────────────────── + + public UUID getId() { return id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getEnvironment() { return environment; } + public void setEnvironment(String environment) { this.environment = environment; } + + public String getOwnerId() { return ownerId; } + public void setOwnerId(String ownerId) { this.ownerId = ownerId; } + + public Instant getCreatedAt() { return createdAt; } +} diff --git a/backend/src/main/java/com/envsync/backend/model/Snapshot.java b/backend/src/main/java/com/envsync/backend/model/Snapshot.java new file mode 100644 index 0000000..3eedd15 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/model/Snapshot.java @@ -0,0 +1,69 @@ +package com.envsync.backend.model; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "snapshots") +public class Snapshot { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + /** Monotonically increasing per-project version number (1, 2, 3, …). */ + @Column(nullable = false) + private int version; + + /** Base64-encoded XSalsa20-Poly1305 ciphertext of the vault. */ + @Column(columnDefinition = "TEXT", nullable = false) + private String ciphertext; + + /** Base64-encoded 24-byte nonce. */ + @Column(nullable = false, length = 64) + private String nonce; + + @Column + private String message; + + /** Keycloak `sub` of the user who pushed this snapshot. */ + @Column(name = "pushed_by") + private String pushedBy; + + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + } + + // ── Getters / Setters ────────────────────────────────────────────────── + + public UUID getId() { return id; } + + public Project getProject() { return project; } + public void setProject(Project project) { this.project = project; } + + public int getVersion() { return version; } + public void setVersion(int version) { this.version = version; } + + public String getCiphertext() { return ciphertext; } + public void setCiphertext(String ciphertext) { this.ciphertext = ciphertext; } + + public String getNonce() { return nonce; } + public void setNonce(String nonce) { this.nonce = nonce; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public String getPushedBy() { return pushedBy; } + public void setPushedBy(String pushedBy) { this.pushedBy = pushedBy; } + + public Instant getCreatedAt() { return createdAt; } +} diff --git a/backend/src/main/java/com/envsync/backend/model/UserPubkey.java b/backend/src/main/java/com/envsync/backend/model/UserPubkey.java new file mode 100644 index 0000000..c5ccfca --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/model/UserPubkey.java @@ -0,0 +1,37 @@ +package com.envsync.backend.model; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "user_pubkeys") +public class UserPubkey { + + /** Keycloak `sub` — primary key (one keypair per user). */ + @Id + @Column(name = "user_id") + private String userId; + + /** Base64-encoded X25519 public key. */ + @Column(name = "public_key", nullable = false) + private String publicKey; + + @Column(name = "updated_at") + private Instant updatedAt; + + @PrePersist + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + // ── Getters / Setters ────────────────────────────────────────────────── + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getPublicKey() { return publicKey; } + public void setPublicKey(String publicKey) { this.publicKey = publicKey; } + + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/backend/src/main/java/com/envsync/backend/repository/ProjectRepository.java b/backend/src/main/java/com/envsync/backend/repository/ProjectRepository.java new file mode 100644 index 0000000..b650ba2 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/repository/ProjectRepository.java @@ -0,0 +1,15 @@ +package com.envsync.backend.repository; + +import com.envsync.backend.model.Project; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ProjectRepository extends JpaRepository { + + List findByOwnerId(String ownerId); + + Optional findByIdAndOwnerId(UUID id, String ownerId); +} diff --git a/backend/src/main/java/com/envsync/backend/repository/SnapshotRepository.java b/backend/src/main/java/com/envsync/backend/repository/SnapshotRepository.java new file mode 100644 index 0000000..bd6d954 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/repository/SnapshotRepository.java @@ -0,0 +1,22 @@ +package com.envsync.backend.repository; + +import com.envsync.backend.model.Project; +import com.envsync.backend.model.Snapshot; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface SnapshotRepository extends JpaRepository { + + Optional findTopByProjectOrderByVersionDesc(Project project); + + List findByProjectOrderByVersionDesc(Project project, Pageable pageable); + + @Query("SELECT COALESCE(MAX(s.version), 0) FROM Snapshot s WHERE s.project = :project") + int findMaxVersionByProject(@Param("project") Project project); +} diff --git a/backend/src/main/java/com/envsync/backend/repository/UserPubkeyRepository.java b/backend/src/main/java/com/envsync/backend/repository/UserPubkeyRepository.java new file mode 100644 index 0000000..9a36c7c --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/repository/UserPubkeyRepository.java @@ -0,0 +1,7 @@ +package com.envsync.backend.repository; + +import com.envsync.backend.model.UserPubkey; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPubkeyRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/envsync/backend/service/ProjectService.java b/backend/src/main/java/com/envsync/backend/service/ProjectService.java new file mode 100644 index 0000000..4d414a7 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/service/ProjectService.java @@ -0,0 +1,45 @@ +package com.envsync.backend.service; + +import com.envsync.backend.model.Project; +import com.envsync.backend.repository.ProjectRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +@Service +public class ProjectService { + + private final ProjectRepository projectRepository; + + public ProjectService(ProjectRepository projectRepository) { + this.projectRepository = projectRepository; + } + + @Transactional + public Project create(String name, String environment, String ownerId) { + Project project = new Project(); + project.setName(name); + project.setEnvironment(environment); + project.setOwnerId(ownerId); + return projectRepository.save(project); + } + + public List listForUser(String ownerId) { + return projectRepository.findByOwnerId(ownerId); + } + + public Project getForUser(UUID id, String ownerId) { + return projectRepository.findByIdAndOwnerId(id, ownerId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Project not found")); + } + + @Transactional + public void delete(UUID id, String ownerId) { + Project project = getForUser(id, ownerId); + projectRepository.delete(project); + } +} diff --git a/backend/src/main/java/com/envsync/backend/service/SnapshotService.java b/backend/src/main/java/com/envsync/backend/service/SnapshotService.java new file mode 100644 index 0000000..64e7e14 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/service/SnapshotService.java @@ -0,0 +1,57 @@ +package com.envsync.backend.service; + +import com.envsync.backend.model.Project; +import com.envsync.backend.model.Snapshot; +import com.envsync.backend.repository.SnapshotRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +@Service +public class SnapshotService { + + private final SnapshotRepository snapshotRepository; + + public SnapshotService(SnapshotRepository snapshotRepository) { + this.snapshotRepository = snapshotRepository; + } + + @Transactional + public Snapshot push(Project project, String ciphertext, String nonce, + String message, String pushedBy) { + int nextVersion = snapshotRepository.findMaxVersionByProject(project) + 1; + + Snapshot snap = new Snapshot(); + snap.setProject(project); + snap.setVersion(nextVersion); + snap.setCiphertext(ciphertext); + snap.setNonce(nonce); + snap.setMessage(message); + snap.setPushedBy(pushedBy); + return snapshotRepository.save(snap); + } + + public Snapshot getLatest(Project project) { + return snapshotRepository.findTopByProjectOrderByVersionDesc(project) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No snapshots found")); + } + + public List list(Project project, int limit) { + return snapshotRepository.findByProjectOrderByVersionDesc( + project, PageRequest.of(0, limit)); + } + + public Snapshot getById(Project project, UUID snapId) { + Snapshot snap = snapshotRepository.findById(snapId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found")); + if (!snap.getProject().getId().equals(project.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found"); + } + return snap; + } +} diff --git a/backend/src/main/java/com/envsync/backend/service/UserPubkeyService.java b/backend/src/main/java/com/envsync/backend/service/UserPubkeyService.java new file mode 100644 index 0000000..64f13c2 --- /dev/null +++ b/backend/src/main/java/com/envsync/backend/service/UserPubkeyService.java @@ -0,0 +1,24 @@ +package com.envsync.backend.service; + +import com.envsync.backend.model.UserPubkey; +import com.envsync.backend.repository.UserPubkeyRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserPubkeyService { + + private final UserPubkeyRepository repo; + + public UserPubkeyService(UserPubkeyRepository repo) { + this.repo = repo; + } + + @Transactional + public void upsert(String userId, String publicKey) { + UserPubkey entry = repo.findById(userId).orElseGet(UserPubkey::new); + entry.setUserId(userId); + entry.setPublicKey(publicKey); + repo.save(entry); + } +} diff --git a/backend/src/test/java/com/envsync/backend/BackendApplicationTests.java b/backend/src/test/java/com/envsync/backend/BackendApplicationTests.java index 59f35f8..1c50667 100644 --- a/backend/src/test/java/com/envsync/backend/BackendApplicationTests.java +++ b/backend/src/test/java/com/envsync/backend/BackendApplicationTests.java @@ -1,13 +1,18 @@ package com.envsync.backend; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +/** + * Integration test — requires the full Docker Compose stack (PostgreSQL, Keycloak). + * Run with: docker compose up -d && ./mvnw test + */ @SpringBootTest +@Disabled("Requires running Docker Compose stack (PostgreSQL + Keycloak). Run with integration profile.") class BackendApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() { + } } diff --git a/backend/src/test/java/com/envsync/backend/service/ProjectServiceTest.java b/backend/src/test/java/com/envsync/backend/service/ProjectServiceTest.java new file mode 100644 index 0000000..92732ac --- /dev/null +++ b/backend/src/test/java/com/envsync/backend/service/ProjectServiceTest.java @@ -0,0 +1,104 @@ +package com.envsync.backend.service; + +import com.envsync.backend.model.Project; +import com.envsync.backend.repository.ProjectRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProjectServiceTest { + + @Mock + private ProjectRepository projectRepository; + + @InjectMocks + private ProjectService projectService; + + private Project sampleProject; + private final String ownerId = "user-123"; + + @BeforeEach + void setUp() { + sampleProject = new Project(); + sampleProject.setName("test-app"); + sampleProject.setEnvironment("development"); + sampleProject.setOwnerId(ownerId); + } + + @Test + void create_savesAndReturnsProject() { + when(projectRepository.save(any(Project.class))).thenReturn(sampleProject); + + Project result = projectService.create("test-app", "development", ownerId); + + assertThat(result.getName()).isEqualTo("test-app"); + assertThat(result.getEnvironment()).isEqualTo("development"); + assertThat(result.getOwnerId()).isEqualTo(ownerId); + verify(projectRepository, times(1)).save(any(Project.class)); + } + + @Test + void listForUser_returnsUserProjects() { + when(projectRepository.findByOwnerId(ownerId)).thenReturn(List.of(sampleProject)); + + List result = projectService.listForUser(ownerId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("test-app"); + } + + @Test + void getForUser_whenFound_returnsProject() { + UUID id = UUID.randomUUID(); + when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject)); + + Project result = projectService.getForUser(id, ownerId); + + assertThat(result).isEqualTo(sampleProject); + } + + @Test + void getForUser_whenNotFound_throws404() { + UUID id = UUID.randomUUID(); + when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> projectService.getForUser(id, ownerId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Project not found"); + } + + @Test + void delete_whenFound_deletesProject() { + UUID id = UUID.randomUUID(); + when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject)); + + projectService.delete(id, ownerId); + + verify(projectRepository, times(1)).delete(sampleProject); + } + + @Test + void delete_whenNotFound_throws404() { + UUID id = UUID.randomUUID(); + when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> projectService.delete(id, ownerId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Project not found"); + verify(projectRepository, never()).delete(any()); + } +} diff --git a/backend/src/test/java/com/envsync/backend/service/SnapshotServiceTest.java b/backend/src/test/java/com/envsync/backend/service/SnapshotServiceTest.java new file mode 100644 index 0000000..5722a18 --- /dev/null +++ b/backend/src/test/java/com/envsync/backend/service/SnapshotServiceTest.java @@ -0,0 +1,150 @@ +package com.envsync.backend.service; + +import com.envsync.backend.model.Project; +import com.envsync.backend.model.Snapshot; +import com.envsync.backend.repository.SnapshotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SnapshotServiceTest { + + @Mock + private SnapshotRepository snapshotRepository; + + @InjectMocks + private SnapshotService snapshotService; + + private Project project; + + @BeforeEach + void setUp() { + project = new Project(); + project.setName("test-app"); + project.setEnvironment("development"); + project.setOwnerId("user-123"); + } + + @Test + void push_createsSnapshotWithNextVersion() { + when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(5); + Snapshot saved = new Snapshot(); + saved.setVersion(6); + when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved); + + Snapshot result = snapshotService.push(project, "ct", "nonce", "init", "user-123"); + + assertThat(result.getVersion()).isEqualTo(6); + verify(snapshotRepository).save(argThat(s -> + s.getVersion() == 6 && + s.getCiphertext().equals("ct") && + s.getNonce().equals("nonce") && + s.getMessage().equals("init") && + s.getPushedBy().equals("user-123") + )); + } + + @Test + void push_firstSnapshot_usesVersion1() { + when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(0); + Snapshot saved = new Snapshot(); + saved.setVersion(1); + when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved); + + Snapshot result = snapshotService.push(project, "ct", "nonce", null, "user-123"); + + assertThat(result.getVersion()).isEqualTo(1); + } + + @Test + void getLatest_whenExists_returnsSnapshot() { + Snapshot snap = new Snapshot(); + snap.setVersion(3); + when(snapshotRepository.findTopByProjectOrderByVersionDesc(project)) + .thenReturn(Optional.of(snap)); + + Snapshot result = snapshotService.getLatest(project); + + assertThat(result.getVersion()).isEqualTo(3); + } + + @Test + void getLatest_whenNone_throws404() { + when(snapshotRepository.findTopByProjectOrderByVersionDesc(project)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> snapshotService.getLatest(project)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("No snapshots found"); + } + + @Test + void getById_whenBelongsToProject_returnsSnapshot() { + UUID snapId = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + + // Need to give project an ID for comparison + Project proj = mock(Project.class); + when(proj.getId()).thenReturn(projectId); + + Snapshot snap = new Snapshot(); + snap.setProject(proj); + when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap)); + + Project requestedProject = mock(Project.class); + when(requestedProject.getId()).thenReturn(projectId); + + Snapshot result = snapshotService.getById(requestedProject, snapId); + assertThat(result).isEqualTo(snap); + } + + @Test + void getById_whenBelongsToDifferentProject_throws404() { + UUID snapId = UUID.randomUUID(); + + Project proj = mock(Project.class); + when(proj.getId()).thenReturn(UUID.randomUUID()); + + Snapshot snap = new Snapshot(); + snap.setProject(proj); + when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap)); + + Project otherProject = mock(Project.class); + when(otherProject.getId()).thenReturn(UUID.randomUUID()); + + assertThatThrownBy(() -> snapshotService.getById(otherProject, snapId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Snapshot not found"); + } + + @Test + void list_returnsPagedResults() { + Snapshot s1 = new Snapshot(); + s1.setVersion(2); + Snapshot s2 = new Snapshot(); + s2.setVersion(1); + when(snapshotRepository.findByProjectOrderByVersionDesc(eq(project), any(Pageable.class))) + .thenReturn(List.of(s1, s2)); + + List result = snapshotService.list(project, 10); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getVersion()).isEqualTo(2); + } +} diff --git a/cli/.DS_Store b/cli/.DS_Store new file mode 100644 index 0000000..f479398 Binary files /dev/null and b/cli/.DS_Store differ diff --git a/cli/envsync-cli/Cargo.lock b/cli/envsync-cli/Cargo.lock index 5f805c8..9e880aa 100644 --- a/cli/envsync-cli/Cargo.lock +++ b/cli/envsync-cli/Cargo.lock @@ -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" diff --git a/cli/envsync-cli/Cargo.toml b/cli/envsync-cli/Cargo.toml index 7b913bc..bc0f4a3 100644 --- a/cli/envsync-cli/Cargo.toml +++ b/cli/envsync-cli/Cargo.toml @@ -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" diff --git a/cli/envsync-cli/src/api/auth.rs b/cli/envsync-cli/src/api/auth.rs index a6b7e9f..d2adfa2 100644 --- a/cli/envsync-cli/src/api/auth.rs +++ b/cli/envsync-cli/src/api/auth.rs @@ -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, - expires_in: u64, +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: u64, } -pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result { - // 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 { + 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(¶ms).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 { + 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(¶ms).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(¶ms).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"); -} \ No newline at end of file +} diff --git a/cli/envsync-cli/src/api/mod.rs b/cli/envsync-cli/src/api/mod.rs index 0e4a05d..d457e7d 100644 --- a/cli/envsync-cli/src/api/mod.rs +++ b/cli/envsync-cli/src/api/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod vault; diff --git a/cli/envsync-cli/src/api/vault.rs b/cli/envsync-cli/src/api/vault.rs index e69de29..5a4e778 100644 --- a/cli/envsync-cli/src/api/vault.rs +++ b/cli/envsync-cli/src/api/vault.rs @@ -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, +} + +#[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, + 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, + pub created_at: String, +} + +// ── Client ───────────────────────────────────────────────────────────────── + +pub struct VaultClient { + client: Client, + api_url: String, + token: String, +} + +impl VaultClient { + pub fn new(api_url: impl Into, token: impl Into) -> Self { + Self { + client: Client::new(), + api_url: api_url.into(), + token: token.into(), + } + } + + async fn check(&self, resp: reqwest::Response) -> Result { + 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 { + 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, 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 { + 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, 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, 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 { + 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(()) + } +} diff --git a/cli/envsync-cli/src/cli.rs b/cli/envsync-cli/src/cli.rs index e69de29..0961c45 100644 --- a/cli/envsync-cli/src/cli.rs +++ b/cli/envsync-cli/src/cli.rs @@ -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, + + #[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, + /// Environment (e.g. development, staging, production) + #[arg(long, short, default_value = "development")] + env: Option, + }, + + /// Encrypt and push the local .env to the server + Push { + /// Commit message describing this snapshot + #[arg(long, short)] + message: Option, + /// 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, + }, + + /// Remove a key from the vault and push a new snapshot + Unset { + key: String, + /// Commit message + #[arg(long, short)] + message: Option, + }, + + /// 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, +} diff --git a/cli/envsync-cli/src/commands/diff.rs b/cli/envsync-cli/src/commands/diff.rs index e69de29..1c6b162 100644 --- a/cli/envsync-cli/src/commands/diff.rs +++ b/cli/envsync-cli/src/commands/diff.rs @@ -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 = 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 = 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(()) +} diff --git a/cli/envsync-cli/src/commands/init.rs b/cli/envsync-cli/src/commands/init.rs index 519c227..ed90cc0 100644 --- a/cli/envsync-cli/src/commands/init.rs +++ b/cli/envsync-cli/src/commands/init.rs @@ -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, - pub expires_at: chrono::DateTime, - 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, + env: Option, + 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, // 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, - pub keypair: Option, - pub current_context: Option, -} + 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 { - 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 { - 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, environment: Option) -> Result { - 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, - }) - } -} \ No newline at end of file diff --git a/cli/envsync-cli/src/commands/log.rs b/cli/envsync-cli/src/commands/log.rs index e69de29..45a0672 100644 --- a/cli/envsync-cli/src/commands/log.rs +++ b/cli/envsync-cli/src/commands/log.rs @@ -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(()) +} diff --git a/cli/envsync-cli/src/commands/login.rs b/cli/envsync-cli/src/commands/login.rs new file mode 100644 index 0000000..5612b89 --- /dev/null +++ b/cli/envsync-cli/src/commands/login.rs @@ -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(()) +} diff --git a/cli/envsync-cli/src/commands/logout.rs b/cli/envsync-cli/src/commands/logout.rs new file mode 100644 index 0000000..60e18e8 --- /dev/null +++ b/cli/envsync-cli/src/commands/logout.rs @@ -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(()) +} diff --git a/cli/envsync-cli/src/commands/mod.rs b/cli/envsync-cli/src/commands/mod.rs index e69de29..b597cc1 100644 --- a/cli/envsync-cli/src/commands/mod.rs +++ b/cli/envsync-cli/src/commands/mod.rs @@ -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; diff --git a/cli/envsync-cli/src/commands/pull.rs b/cli/envsync-cli/src/commands/pull.rs index e69de29..0b5fe80 100644 --- a/cli/envsync-cli/src/commands/pull.rs +++ b/cli/envsync-cli/src/commands/pull.rs @@ -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 = 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, +) -> Result<()> { + let content: String = map + .iter() + .map(|(k, v)| format!("{k}={v}\n")) + .collect(); + std::fs::write(path, content)?; + Ok(()) +} diff --git a/cli/envsync-cli/src/commands/push.rs b/cli/envsync-cli/src/commands/push.rs index e69de29..e495d28 100644 --- a/cli/envsync-cli/src/commands/push.rs +++ b/cli/envsync-cli/src/commands/push.rs @@ -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, 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> { + let mut map = BTreeMap::new(); + for item in dotenvy::from_path_iter(path)? { + let (k, v) = item?; + map.insert(k, v); + } + Ok(map) +} diff --git a/cli/envsync-cli/src/commands/rollback.rs b/cli/envsync-cli/src/commands/rollback.rs index e69de29..4cca362 100644 --- a/cli/envsync-cli/src/commands/rollback.rs +++ b/cli/envsync-cli/src/commands/rollback.rs @@ -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 = 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(()) +} diff --git a/cli/envsync-cli/src/commands/set.rs b/cli/envsync-cli/src/commands/set.rs index e69de29..6eda653 100644 --- a/cli/envsync-cli/src/commands/set.rs +++ b/cli/envsync-cli/src/commands/set.rs @@ -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, 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 = + 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(()) +} diff --git a/cli/envsync-cli/src/commands/status.rs b/cli/envsync-cli/src/commands/status.rs new file mode 100644 index 0000000..e24b544 --- /dev/null +++ b/cli/envsync-cli/src/commands/status.rs @@ -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::::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(()) +} diff --git a/cli/envsync-cli/src/commands/unset.rs b/cli/envsync-cli/src/commands/unset.rs index e69de29..c4b9a29 100644 --- a/cli/envsync-cli/src/commands/unset.rs +++ b/cli/envsync-cli/src/commands/unset.rs @@ -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, 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 = + 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(()) +} diff --git a/cli/envsync-cli/src/config.rs b/cli/envsync-cli/src/config.rs index e69de29..861de97 100644 --- a/cli/envsync-cli/src/config.rs +++ b/cli/envsync-cli/src/config.rs @@ -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, + /// 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, + pub keypair: Option, +} + +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 { + 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 { + 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 { + 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(()) + } +} diff --git a/cli/envsync-cli/src/crypto.rs b/cli/envsync-cli/src/crypto.rs index e69de29..a38a27c 100644 --- a/cli/envsync-cli/src/crypto.rs +++ b/cli/envsync-cli/src/crypto.rs @@ -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, 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, 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, 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 { + 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); + } +} diff --git a/cli/envsync-cli/src/error.rs b/cli/envsync-cli/src/error.rs index e69de29..d813d13 100644 --- a/cli/envsync-cli/src/error.rs +++ b/cli/envsync-cli/src/error.rs @@ -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), +} diff --git a/cli/envsync-cli/src/main.rs b/cli/envsync-cli/src/main.rs index d516fed..a1638c7 100644 --- a/cli/envsync-cli/src/main.rs +++ b/cli/envsync-cli/src/main.rs @@ -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(()) - - -} \ No newline at end of file + if let Err(e) = result { + eprintln!("Error: {e:#}"); + std::process::exit(1); + } +} diff --git a/frontend/src/app/dashbaord/layout.tsx b/frontend/src/app/dashbaord/layout.tsx index e69de29..fe5dff9 100644 --- a/frontend/src/app/dashbaord/layout.tsx +++ b/frontend/src/app/dashbaord/layout.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { redirect } from "next/navigation"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + if (!session) redirect("/api/auth/signin/keycloak"); + + const username = session.preferredUsername ?? session.user?.email ?? "user"; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {children} +
+
+ ); +} + +function GridIcon() { + return ( + + + + ); +} + +function LogoutIcon() { + return ( + + + + ); +} diff --git a/frontend/src/app/dashbaord/page.tsx b/frontend/src/app/dashbaord/page.tsx index e69de29..0694618 100644 --- a/frontend/src/app/dashbaord/page.tsx +++ b/frontend/src/app/dashbaord/page.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; +import { createApiClient, type Project } from "@/lib/api"; + +const ENV_COLORS: Record = { + production: "bg-red-500/10 text-red-400 border-red-500/20", + staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", + development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", +}; + +function EnvBadge({ env }: { env: string }) { + const cls = ENV_COLORS[env.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700"; + return ( + + {env} + + ); +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// ── Create Project Modal ─────────────────────────────────────────────────── + +const ENVIRONMENTS = ["development", "staging", "production", "test"]; + +function CreateProjectModal({ + onClose, + onCreate, +}: { + onClose: () => void; + onCreate: (name: string, env: string) => Promise; +}) { + const [name, setName] = useState(""); + const [env, setEnv] = useState("development"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + setLoading(true); + setError(null); + try { + await onCreate(name.trim(), env); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create project"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

New Project

+

+ Create a new encrypted vault for your team. +

+ +
+
+ + setName(e.target.value)} + placeholder="my-app" + className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50" + autoFocus + required + maxLength={100} + /> +
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ); +} + +// ── Delete Confirmation ──────────────────────────────────────────────────── + +function DeleteConfirmModal({ + project, + onClose, + onDelete, +}: { + project: Project; + onClose: () => void; + onDelete: () => Promise; +}) { + const [loading, setLoading] = useState(false); + + async function handleDelete() { + setLoading(true); + try { + await onDelete(); + onClose(); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Delete project?

+

+ This will permanently delete{" "} + {project.name} and all + its snapshots. This action cannot be undone. +

+
+ + +
+
+
+ ); +} + +// ── Project Card ─────────────────────────────────────────────────────────── + +function ProjectCard({ + project, + onDelete, +}: { + project: Project; + onDelete: (p: Project) => void; +}) { + return ( +
+
+
+ + {project.name} + +

+ Created {formatDate(project.created_at)} +

+
+
+ + +
+
+ +
+ + + View snapshots + +
+
+ ); +} + +function TrashIcon() { + return ( + + + + ); +} + +function HistoryIcon() { + return ( + + + + ); +} + +// ── Page ─────────────────────────────────────────────────────────────────── + +export default function DashboardPage() { + const { data: session, status } = useSession(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const fetchProjects = useCallback(async () => { + if (!session?.accessToken) return; + setLoading(true); + setError(null); + try { + const api = createApiClient(session.accessToken); + const list = await api.listProjects(); + setProjects(list); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load projects"); + } finally { + setLoading(false); + } + }, [session?.accessToken]); + + useEffect(() => { + if (status === "authenticated") { + fetchProjects(); + } + }, [status, fetchProjects]); + + async function handleCreate(name: string, env: string) { + if (!session?.accessToken) return; + const api = createApiClient(session.accessToken); + await api.createProject(name, env); + await fetchProjects(); + } + + async function handleDelete() { + if (!session?.accessToken || !deleteTarget) return; + const api = createApiClient(session.accessToken); + await api.deleteProject(deleteTarget.id); + setDeleteTarget(null); + await fetchProjects(); + } + + if (status === "loading" || loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Projects

+

+ {projects.length} project{projects.length !== 1 ? "s" : ""} in your account +

+
+ +
+ + {/* CLI hint */} +
+ ${" "} + envsync init --project <name> --env <env> + ← or create via this dashboard +
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Projects grid */} + {projects.length === 0 && !error ? ( +
+
+ +
+

No projects yet

+

+ Create your first project to start syncing encrypted environment variables across your team. +

+ +
+ ) : ( +
+ {projects.map((p) => ( + + ))} +
+ )} + + {/* Modals */} + {showCreate && ( + setShowCreate(false)} + onCreate={handleCreate} + /> + )} + {deleteTarget && ( + setDeleteTarget(null)} + onDelete={handleDelete} + /> + )} +
+ ); +} + +function PlusIcon() { + return ( + + + + ); +} + +function FolderIcon() { + return ( + + + + ); +} diff --git a/frontend/src/app/dashbaord/projects/[id]/page.tsx b/frontend/src/app/dashbaord/projects/[id]/page.tsx new file mode 100644 index 0000000..11d422e --- /dev/null +++ b/frontend/src/app/dashbaord/projects/[id]/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { createApiClient, type Project, type SnapshotMeta } from "@/lib/api"; + +function formatDate(iso: string) { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatRelative(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +const ENV_BADGE: Record = { + production: "bg-red-500/10 text-red-400 border-red-500/20", + staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", + development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", +}; + +// ── Snapshot Row ──────────────────────────────────────────────────────────── + +function SnapshotRow({ + snap, + isLatest, +}: { + snap: SnapshotMeta; + isLatest: boolean; +}) { + return ( +
+ {/* Version bubble */} +
+
+ v{snap.version} +
+ {/* connector line — handled by parent spacing */} +
+ +
+
+ {isLatest && ( + + latest + + )} +

+ {snap.message ?? No message} +

+
+

+ {snap.pushed_by ? ( + <> + {snap.pushed_by.split("-")[0]}… + {" · "} + + ) : null} + {formatRelative(snap.created_at)} +

+
+ +
+ {formatDate(snap.created_at)} +
+
+ ); +} + +// ── CLI Reference Panel ───────────────────────────────────────────────────── + +function CliPanel({ projectId }: { projectId: string }) { + const [copied, setCopied] = useState(null); + + function copy(text: string, key: string) { + navigator.clipboard.writeText(text).then(() => { + setCopied(key); + setTimeout(() => setCopied(null), 1500); + }); + } + + const commands = [ + { key: "push", cmd: "envsync push -m \"describe changes\"", desc: "Encrypt & push local .env" }, + { key: "pull", cmd: "envsync pull", desc: "Pull latest snapshot" }, + { key: "diff", cmd: "envsync diff", desc: "Compare local vs remote" }, + { key: "log", cmd: "envsync log", desc: "View snapshot history" }, + ]; + + return ( +
+

CLI Quick Reference

+

+ Project ID: {projectId} +

+
+ {commands.map(({ key, cmd, desc }) => ( +
+
+ {cmd} +

{desc}

+
+ +
+ ))} +
+
+ ); +} + +// ── Page ─────────────────────────────────────────────────────────────────── + +export default function ProjectDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data: session, status } = useSession(); + const [project, setProject] = useState(null); + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!session?.accessToken || !id) return; + setLoading(true); + setError(null); + try { + const api = createApiClient(session.accessToken); + const [proj, snaps] = await Promise.all([ + api.getProject(id), + api.listSnapshots(id, 50), + ]); + setProject(proj); + setSnapshots(snaps); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load project"); + } finally { + setLoading(false); + } + }, [session?.accessToken, id]); + + useEffect(() => { + if (status === "authenticated") fetchData(); + }, [status, fetchData]); + + if (status === "loading" || loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} + +
+
+ ); + } + + if (!project) return null; + + const envClass = ENV_BADGE[project.environment.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700"; + + return ( +
+ {/* Breadcrumb */} +
+ + Projects + + / + {project.name} +
+ + {/* Project header */} +
+
+
+

{project.name}

+ + {project.environment} + +
+

+ {snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""} · Created {formatDate(project.created_at)} +

+
+
+ +
+ {/* Snapshot history */} +
+
+
+

Snapshot History

+ {snapshots.length} total +
+ + {snapshots.length === 0 ? ( +
+

No snapshots yet.

+

+ Run{" "} + envsync push{" "} + to create the first one. +

+
+ ) : ( +
+ {snapshots.map((snap, i) => ( + + ))} +
+ )} +
+ + {/* Rollback note */} + {snapshots.length > 1 && ( +
+ +

+ To roll back:{" "} + envsync rollback --version <N> +

+
+ )} +
+ + {/* Sidebar */} +
+ + + {/* Stats */} +
+

Stats

+
+ + + +
+
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function CopyIcon() { + return ( + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function InfoIcon() { + return ( + + + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..6468bb0 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,113 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081"; + +export interface Project { + id: string; + name: string; + environment: string; + created_at: string; +} + +export interface SnapshotMeta { + id: string; + version: number; + message: string | null; + pushed_by: string | null; + created_at: string; +} + +export interface SnapshotFull extends SnapshotMeta { + ciphertext: string; + nonce: string; +} + +export interface ApiError { + status: number; + error: string; + fields?: Record; +} + +class ApiClient { + private token: string; + + constructor(token: string) { + this.token = token; + } + + private async request( + path: string, + init: RequestInit = {} + ): Promise { + const res = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + ...init.headers, + }, + }); + + if (!res.ok) { + let body: ApiError; + try { + body = await res.json(); + } catch { + body = { status: res.status, error: res.statusText }; + } + const err = new Error(body.error ?? `HTTP ${res.status}`); + (err as Error & { apiError: ApiError }).apiError = body; + throw err; + } + + if (res.status === 204) return undefined as unknown as T; + return res.json() as Promise; + } + + // ── Projects ────────────────────────────────────────────────────── + + listProjects(): Promise { + return this.request("/api/projects"); + } + + getProject(id: string): Promise { + return this.request(`/api/projects/${id}`); + } + + createProject(name: string, environment: string): Promise { + return this.request("/api/projects", { + method: "POST", + body: JSON.stringify({ name, environment }), + }); + } + + deleteProject(id: string): Promise { + return this.request(`/api/projects/${id}`, { method: "DELETE" }); + } + + // ── Snapshots ───────────────────────────────────────────────────── + + listSnapshots(projectId: string, limit = 50): Promise { + return this.request( + `/api/projects/${projectId}/snapshots?limit=${limit}` + ); + } + + getLatestSnapshot(projectId: string): Promise { + return this.request( + `/api/projects/${projectId}/snapshots/latest` + ).catch((err) => { + if ((err as Error & { apiError?: ApiError }).apiError?.status === 404) + return null; + throw err; + }); + } + + getSnapshot(projectId: string, snapId: string): Promise { + return this.request( + `/api/projects/${projectId}/snapshots/${snapId}` + ); + } +} + +export function createApiClient(token: string): ApiClient { + return new ApiClient(token); +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index a6b3500..4cf7cd5 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,14 +1,44 @@ import type { NextAuthOptions } from "next-auth"; +import type { JWT } from "next-auth/jwt"; import KeycloakProvider from "next-auth/providers/keycloak"; function requiredEnv(name: string): string { const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); +async function refreshAccessToken(token: JWT): Promise { + const issuer = requiredEnv("KEYCLOAK_ISSUER"); + const tokenUrl = `${issuer}/protocol/openid-connect/token`; + + const res = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: requiredEnv("KEYCLOAK_CLIENT_ID"), + client_secret: requiredEnv("KEYCLOAK_CLIENT_SECRET"), + refresh_token: String(token.refreshToken ?? ""), + }), + }); + + const refreshed = await res.json(); + + if (!res.ok) { + // Refresh failed — force re-login by returning an error token + return { ...token, error: "RefreshAccessTokenError" }; } - return value; + return { + ...token, + accessToken: refreshed.access_token, + idToken: refreshed.id_token ?? token.idToken, + refreshToken: refreshed.refresh_token ?? token.refreshToken, + // expires_in is seconds from now + accessTokenExpiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in, + error: undefined, + }; } export const authOptions: NextAuthOptions = { @@ -17,6 +47,7 @@ export const authOptions: NextAuthOptions = { clientId: requiredEnv("KEYCLOAK_CLIENT_ID"), clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"), issuer: requiredEnv("KEYCLOAK_ISSUER"), + authorization: { params: { scope: "openid email profile offline_access" } }, }), ], session: { @@ -24,20 +55,41 @@ export const authOptions: NextAuthOptions = { }, callbacks: { async jwt({ token, account, profile }) { + // Initial sign-in — store everything from the provider if (account) { token.accessToken = account.access_token; token.idToken = account.id_token; + token.refreshToken = account.refresh_token; + token.accessTokenExpiresAt = account.expires_at; // unix seconds } if (profile && typeof profile === "object" && "preferred_username" in profile) { token.preferredUsername = String(profile.preferred_username); } - return token; - }, - async session({ session, token }) { - session.preferredUsername = typeof token.preferredUsername === "string" ? token.preferredUsername : undefined; + // Token still valid (with 60-second buffer) + const expiresAt = typeof token.accessTokenExpiresAt === "number" + ? token.accessTokenExpiresAt + : 0; + if (Math.floor(Date.now() / 1000) < expiresAt - 60) { + return token; + } + // Access token expired — try to refresh + return refreshAccessToken(token); + }, + + async session({ session, token }) { + session.preferredUsername = typeof token.preferredUsername === "string" + ? token.preferredUsername + : undefined; + session.accessToken = typeof token.accessToken === "string" + ? token.accessToken + : undefined; + // Expose refresh errors to the client so it can redirect to sign-in + if (token.error) { + (session as typeof session & { error?: string }).error = token.error as string; + } return session; }, }, diff --git a/frontend/src/types/next-auth.d.ts b/frontend/src/types/next-auth.d.ts index 155d795..bacd843 100644 --- a/frontend/src/types/next-auth.d.ts +++ b/frontend/src/types/next-auth.d.ts @@ -3,6 +3,7 @@ import "next-auth/jwt"; declare module "next-auth" { interface Session { + accessToken?: string; preferredUsername?: string; } } @@ -11,6 +12,9 @@ declare module "next-auth/jwt" { interface JWT { accessToken?: string; idToken?: string; + refreshToken?: string; + accessTokenExpiresAt?: number; // unix seconds preferredUsername?: string; + error?: string; } }