diff --git a/backend/app/api.py b/backend/app/api.py index 1c57a30..504e4eb 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -211,9 +211,10 @@ async def init_workspace_git(): @router.get("/workspace/status") async def get_workspace_status(): vault_path = storage.base_dir / ".litefetch" / "vault.key" - locked = vault_path.exists() and storage.master_key is None + ciphertext = storage.has_encrypted_payloads_without_key() + locked = (vault_path.exists() and storage.master_key is None) or ciphertext legacy = storage.has_legacy_inline_encryption() - return {"locked": locked, "legacy": legacy, "has_vault": vault_path.exists()} + return {"locked": locked, "legacy": legacy, "has_vault": vault_path.exists(), "ciphertext": ciphertext} @router.post("/workspace/unlock") diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py index e2a98d8..e1af88a 100644 --- a/backend/app/core/crypto.py +++ b/backend/app/core/crypto.py @@ -61,10 +61,27 @@ def encrypt_value(plaintext: str, key: Union[str, bytes]) -> str: return "enc:" + payload +def _strip_prefix(value: str) -> str: + """ + Accept both canonical 'enc:v1|...' and legacy 'encv1|...' payloads. + """ + if value.startswith("enc:"): + return value[len("enc:") :] + if value.startswith("enc") and len(value) > 3: + # Handle colon-less format like 'encv1|master|...' + body = value[3:] + if body.startswith(":"): + body = body[1:] + return body + raise CryptoError("not encrypted") + + +def is_encrypted_string(value: str) -> bool: + return isinstance(value, str) and (value.startswith("enc:") or value.startswith("encv")) + + def _parse_encrypted(value: str) -> Tuple[str, str, int, bytes, bytes, bytes]: - if not value.startswith("enc:"): - raise CryptoError("not encrypted") - body = value[len("enc:") :] + body = _strip_prefix(value) parts = body.split("|") if len(parts) != 6: raise CryptoError("invalid payload format") diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index 7d1926c..3d8db8e 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -24,6 +24,7 @@ CryptoError, _derive_key, _parse_encrypted, + is_encrypted_string, KDF_NAME, KEY_LEN, ) @@ -264,7 +265,7 @@ def _encrypt_request_secrets(self, req: Any) -> Any: secret_headers = getattr(r, "secret_headers", {}) or {} next_headers = {} for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and not v.startswith("enc:"): + if secret_headers.get(k) and isinstance(v, str) and not is_encrypted_string(v): next_headers[k] = encrypt_value(v, self.master_key) else: next_headers[k] = v @@ -277,7 +278,7 @@ def _encrypt_request_secrets(self, req: Any) -> Any: row_copy = dict(row) key = row_copy.get("key") or "" val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and not str(val).startswith("enc:"): + if secret_q.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): row_copy["value"] = encrypt_value(str(val), self.master_key) next_q.append(row_copy) r.query_params = next_q @@ -291,7 +292,7 @@ def _encrypt_request_secrets(self, req: Any) -> Any: val = row_copy.get("value") row_type = (row_copy.get("type") or "text").lower() # Only encrypt textual form values; do not encrypt file paths or blobs - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and not str(val).startswith("enc:"): + if row_type == "text" and secret_form.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): row_copy["value"] = encrypt_value(str(val), self.master_key) next_form.append(row_copy) r.form_body = next_form @@ -300,7 +301,7 @@ def _encrypt_request_secrets(self, req: Any) -> Any: secret_auth = getattr(r, "secret_auth_params", {}) or {} next_auth = {} for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and not v.startswith("enc:"): + if secret_auth.get(k) and isinstance(v, str) and not is_encrypted_string(v): next_auth[k] = encrypt_value(v, self.master_key) else: next_auth[k] = v @@ -309,7 +310,7 @@ def _encrypt_request_secrets(self, req: Any) -> Any: # Body if getattr(r, "secret_body", False): body_val = r.body - if isinstance(body_val, str) and not body_val.startswith("enc:"): + if isinstance(body_val, str) and not is_encrypted_string(body_val): r.body = encrypt_value(body_val, self.master_key) elif isinstance(body_val, dict): try: @@ -327,7 +328,7 @@ def _decrypt_request_secrets(self, req: Any) -> Any: secret_headers = getattr(r, "secret_headers", {}) or {} next_headers = {} for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and v.startswith("enc:"): + if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): try: next_headers[k] = decrypt_value(v, self.master_key) except CryptoError: @@ -343,7 +344,7 @@ def _decrypt_request_secrets(self, req: Any) -> Any: row_copy = dict(row) key = row_copy.get("key") or "" val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and val.startswith("enc:"): + if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): try: row_copy["value"] = decrypt_value(val, self.master_key) except CryptoError: @@ -359,7 +360,7 @@ def _decrypt_request_secrets(self, req: Any) -> Any: key = row_copy.get("key") or "" val = row_copy.get("value") row_type = (row_copy.get("type") or "text").lower() - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and val.startswith("enc:"): + if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): try: row_copy["value"] = decrypt_value(val, self.master_key) except CryptoError: @@ -371,7 +372,7 @@ def _decrypt_request_secrets(self, req: Any) -> Any: secret_auth = getattr(r, "secret_auth_params", {}) or {} next_auth = {} for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and v.startswith("enc:"): + if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): try: next_auth[k] = decrypt_value(v, self.master_key) except CryptoError: @@ -381,7 +382,7 @@ def _decrypt_request_secrets(self, req: Any) -> Any: r.auth_params = next_auth # Body - if getattr(r, "secret_body", False) and isinstance(r.body, str) and r.body.startswith("enc:"): + if getattr(r, "secret_body", False) and isinstance(r.body, str) and is_encrypted_string(r.body): try: raw = decrypt_value(r.body, self.master_key) try: @@ -409,6 +410,68 @@ def _walk_requests(self, items: list, fn) -> list: result.append(transformed) return result + def _walk_requests_any(self, items: list, predicate) -> bool: + """ + Walk the request tree and return True on the first match for predicate. + """ + for item in items: + if isinstance(item, dict) and "items" in item: + if self._walk_requests_any(item.get("items", []), predicate): + return True + elif hasattr(item, "items"): + if self._walk_requests_any(getattr(item, "items", []) or [], predicate): + return True + else: + req_obj = item if hasattr(item, "id") else item + if predicate(req_obj): + return True + return False + + def _request_contains_encrypted_secret(self, req: Any) -> bool: + secret_headers = getattr(req, "secret_headers", {}) or {} + for k, v in (getattr(req, "headers", {}) or {}).items(): + if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): + return True + + secret_q = getattr(req, "secret_query_params", {}) or {} + for row in getattr(req, "query_params", []) or []: + key = (row or {}).get("key") or "" + val = (row or {}).get("value") + if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): + return True + + secret_form = getattr(req, "secret_form_fields", {}) or {} + for row in getattr(req, "form_body", []) or []: + key = (row or {}).get("key") or "" + val = (row or {}).get("value") + row_type = ((row or {}).get("type") or "text").lower() + if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): + return True + + secret_auth = getattr(req, "secret_auth_params", {}) or {} + for k, v in (getattr(req, "auth_params", {}) or {}).items(): + if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): + return True + + if getattr(req, "secret_body", False) and isinstance(getattr(req, "body", None), str): + if is_encrypted_string(getattr(req, "body")): + return True + return False + + def _collection_contains_encrypted_secrets(self, col: Collection) -> bool: + return self._walk_requests_any(col.items or [], self._request_contains_encrypted_secret) + + def _environment_contains_encrypted_secrets(self, env_file: EnvironmentFile) -> bool: + for env_obj in env_file.envs.values(): + secrets_map = getattr(env_obj, "secrets", {}) or {} + for key, is_secret in secrets_map.items(): + if not is_secret: + continue + val = env_obj.variables.get(key) + if isinstance(val, str) and is_encrypted_string(val): + return True + return False + def reencrypt_sensitive(self) -> Dict[str, int]: """ Rewrites all sensitive assets with the current master key. If the vault is locked, @@ -536,6 +599,9 @@ def load_collection(self, collection_id: str) -> Collection: raw = json.load(f) data, was_wrapped = self._maybe_decrypt_wrapper(raw) col = Collection(**data) + if not self.master_key and self._collection_contains_encrypted_secrets(col): + # Ciphertext present but no key loaded; surface lock state instead of leaking ciphertext. + raise VaultLockedError("workspace locked") if self.master_key: col.items = self._walk_requests(col.items or [], self._decrypt_request_secrets) # If we unwrapped a legacy encrypted blob, rewrite in new format @@ -564,6 +630,8 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: data, was_wrapped = self._maybe_decrypt_wrapper(raw) env_file = EnvironmentFile(**data) if not self.master_key: + if self._environment_contains_encrypted_secrets(env_file): + raise VaultLockedError("workspace locked") return env_file for env_obj in env_file.envs.values(): @@ -572,7 +640,7 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: if not is_secret: continue val = env_obj.variables.get(key) - if isinstance(val, str) and val.startswith("enc:"): + if isinstance(val, str) and is_encrypted_string(val): try: env_obj.variables[key] = decrypt_value(val, self.master_key) except CryptoError: @@ -595,7 +663,7 @@ def save_environment(self, collection_id: str, env: EnvironmentFile): if not is_secret: continue val = env_obj.variables.get(key) - if isinstance(val, str) and val.startswith("enc:"): + if isinstance(val, str) and is_encrypted_string(val): # already encrypted, leave as-is continue if isinstance(val, str): @@ -712,7 +780,7 @@ def has_legacy_inline_encryption(self) -> bool: if not is_secret: continue val = (env_obj or {}).get("variables", {}).get(key) - if isinstance(val, str) and val.startswith("enc:"): + if isinstance(val, str) and is_encrypted_string(val): try: _, kdf_name, _, _, _, _ = _parse_encrypted(val) except CryptoError: @@ -721,6 +789,48 @@ def has_legacy_inline_encryption(self) -> bool: return True return False + def has_encrypted_payloads_without_key(self) -> bool: + """ + Detect inline ciphertext when no master key is loaded so we can signal + the UI to unlock instead of leaking ciphertext into requests. + """ + if self.master_key: + return False + if self.is_locked(): + return True + + for cdir in self.collections_dir.iterdir(): + if not cdir.is_dir(): + continue + cid = cdir.name + collection_path = self._collection_path(cid) + env_path = self._env_path(cid) + + if collection_path.exists(): + try: + with open(collection_path, "r", encoding="utf-8") as f: + raw_col = json.load(f) + if isinstance(raw_col, dict) and "ciphertext" in raw_col: + return True + col = Collection(**raw_col) + if self._collection_contains_encrypted_secrets(col): + return True + except Exception: + pass + + if env_path.exists(): + try: + with open(env_path, "r", encoding="utf-8") as f: + raw_env = json.load(f) + if isinstance(raw_env, dict) and "ciphertext" in raw_env: + return True + env_file = EnvironmentFile(**raw_env) + if self._environment_contains_encrypted_secrets(env_file): + return True + except Exception: + pass + return False + def migrate_legacy_environments(self, passphrase: str, master: bytes) -> Dict[str, int]: stats = {"updated": 0, "collections": 0} for env_path in self.collections_dir.rglob("environment.json"): @@ -737,7 +847,7 @@ def migrate_legacy_environments(self, passphrase: str, master: bytes) -> Dict[st if not is_secret: continue val = (env_obj or {}).get("variables", {}).get(key) - if isinstance(val, str) and val.startswith("enc:"): + if isinstance(val, str) and is_encrypted_string(val): try: _, kdf_name, _, _, _, _ = _parse_encrypted(val) except CryptoError: diff --git a/backend/tests/test_crypto_master.py b/backend/tests/test_crypto_master.py index 434bc80..ec4a469 100644 --- a/backend/tests/test_crypto_master.py +++ b/backend/tests/test_crypto_master.py @@ -17,3 +17,11 @@ def test_legacy_round_trip(): passphrase = "hunter2" cipher = encrypt_value("secret", passphrase) assert decrypt_value(cipher, passphrase) == "secret" + + +def test_master_round_trip_colonless_prefix(): + master = os.urandom(KEY_LEN) + cipher = encrypt_value("secret", master) + compat_cipher = cipher.replace("enc:", "enc", 1) + assert compat_cipher.startswith("encv1|") + assert decrypt_value(compat_cipher, master) == "secret" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fc5b57..696ee63 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,7 @@ function App() { useEffect(() => { LiteAPI.getWorkspaceStatus() - .then(({ locked, legacy }) => setStatus(locked, legacy)) + .then(({ locked, legacy, has_vault, ciphertext }) => setStatus(locked, legacy, has_vault, ciphertext)) .catch(() => setStatus(false, false)); }, [workspace?.path, setStatus]); diff --git a/frontend/src/components/workspace/WorkspacePassphraseModal.tsx b/frontend/src/components/workspace/WorkspacePassphraseModal.tsx index eeaee0c..01aabdf 100644 --- a/frontend/src/components/workspace/WorkspacePassphraseModal.tsx +++ b/frontend/src/components/workspace/WorkspacePassphraseModal.tsx @@ -14,6 +14,7 @@ export const WorkspacePassphraseModal = () => { isLocked, legacyMode, hasVault, + hasCiphertext, setUnlocked, modalOpen, closeModal, @@ -165,6 +166,16 @@ export const WorkspacePassphraseModal = () => { } }, [isLocked, legacyMode, openModal]); + const statusMessage = legacyMode + ? 'Legacy secrets detected. Enter passphrase to migrate and unlock.' + : hasCiphertext + ? 'Encrypted secrets detected but the workspace key is not loaded. Enter the passphrase to decrypt before sending requests.' + : isLocked + ? 'Workspace is locked. Enter passphrase to continue.' + : hasVault + ? 'Manage workspace folder or unlock with passphrase.' + : 'New workspace detected. Set a passphrase to encrypt data, or continue without encryption.'; + const handleMigrate = async () => { setError(null); setMigrateNote(null); @@ -206,15 +217,7 @@ export const WorkspacePassphraseModal = () => { )} -
- {legacyMode - ? 'Legacy secrets detected. Enter passphrase to migrate and unlock.' - : isLocked - ? 'Workspace is locked. Enter passphrase to continue.' - : hasVault - ? 'Manage workspace folder or unlock with passphrase.' - : 'New workspace detected. Set a passphrase to encrypt data, or continue without encryption.'} -
+
{statusMessage}
{error &&
{error}
}
@@ -296,7 +299,7 @@ export const WorkspacePassphraseModal = () => { type="button" className="px-3 py-1.5 text-xs rounded border border-border bg-white hover:bg-muted transition-colors disabled:opacity-50" onClick={() => closeModal()} - disabled={busyUnlock || busySwitch || busyRotate || busyMigrate} + disabled={busyUnlock || busySwitch || busyRotate || busyMigrate || isLocked || legacyMode || hasCiphertext} > Continue without encryption diff --git a/frontend/src/hooks/useWorkspaceManager.ts b/frontend/src/hooks/useWorkspaceManager.ts index 37c807b..4a1f1af 100644 --- a/frontend/src/hooks/useWorkspaceManager.ts +++ b/frontend/src/hooks/useWorkspaceManager.ts @@ -62,8 +62,8 @@ export const useWorkspaceManager = () => { try { const status = await fetchStatusWithRetry(); - setLockStatus(status.locked, status.legacy, (status as any).has_vault ?? true); - if (status.locked || status.legacy || !(status as any).has_vault) { + setLockStatus(status.locked, status.legacy, (status as any).has_vault ?? true, (status as any).ciphertext); + if (status.locked || status.legacy || !(status as any).has_vault || (status as any).ciphertext) { openLockModal(); return resolvedPath; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a87358c..e60cad2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -194,7 +194,9 @@ export const LiteAPI = { }, getWorkspaceStatus: async () => { const client = await getApiClient(); - return client.get<{ locked: boolean; legacy: boolean; has_vault?: boolean }>(`/workspace/status`).then((r) => r.data); + return client + .get<{ locked: boolean; legacy: boolean; has_vault?: boolean; ciphertext?: boolean }>(`/workspace/status`) + .then((r) => r.data); }, unlockWorkspace: async (passphrase: string) => { const client = await getApiClient(); diff --git a/frontend/src/stores/useWorkspaceLockStore.ts b/frontend/src/stores/useWorkspaceLockStore.ts index 724df8c..9854443 100644 --- a/frontend/src/stores/useWorkspaceLockStore.ts +++ b/frontend/src/stores/useWorkspaceLockStore.ts @@ -5,7 +5,8 @@ type LockState = { isLocked: boolean; legacyMode: boolean; hasVault: boolean; - setStatus: (locked: boolean, legacy: boolean, hasVault?: boolean) => void; + hasCiphertext: boolean; + setStatus: (locked: boolean, legacy: boolean, hasVault?: boolean, ciphertext?: boolean) => void; setUnlocked: (passphrase: string) => Promise; modalOpen: boolean; openModal: () => void; @@ -16,20 +17,22 @@ export const useWorkspaceLockStore = create((set) => ({ isLocked: false, legacyMode: false, hasVault: true, + hasCiphertext: false, modalOpen: true, - setStatus: (locked, legacy, hasVault) => + setStatus: (locked, legacy, hasVault, ciphertext) => set((prev) => ({ - isLocked: locked, + isLocked: locked || !!ciphertext, legacyMode: legacy, hasVault: hasVault ?? prev.hasVault, - modalOpen: prev.modalOpen || locked || legacy || !hasVault, + hasCiphertext: ciphertext ?? prev.hasCiphertext, + modalOpen: prev.modalOpen || locked || legacy || !!ciphertext || !(hasVault ?? prev.hasVault), })), openModal: () => set({ modalOpen: true }), closeModal: () => set({ modalOpen: false }), setUnlocked: async (passphrase: string) => { if (!passphrase.trim()) return false; const res = await LiteAPI.unlockWorkspace(passphrase.trim()); - set({ isLocked: false, legacyMode: false, hasVault: true, modalOpen: false }); + set({ isLocked: false, legacyMode: false, hasVault: true, hasCiphertext: false, modalOpen: false }); return res.status === 'unlocked'; }, })); diff --git a/readme.md b/readme.md index 6ae5bcf..f0123d6 100644 --- a/readme.md +++ b/readme.md @@ -21,22 +21,22 @@ Architected with **Tauri v2**, it combines the performance of a native Rust desk ## Key Features -### 🛠️ Powerful Request Editor +### Powerful Request Editor * **Full Method Support:** GET, POST, PUT, DELETE, PATCH, and more. * **Body Masters:** First-class support for `JSON`, `Form-Data` (with file uploads), `x-www-form-urlencoded`, and `Raw` text. * **Auth Built-in:** Native support for Basic Auth and Bearer Tokens. * **Cookie Jar:** Automatic per-environment cookie management (like a real browser). -### 🔗 "Auto-Magic" Request Chaining +### "Auto-Magic" Request Chaining Stop copy-pasting tokens manually. Use **JMESPath** rules to extract data from a response and inject it into your environment variables automatically. * *Example:* Login -> Extract `body.token` -> Save to `{{access_token}}` -> Use in next request. -### 🌍 Dynamic Environments +### Dynamic Environments * Manage sets of variables (`dev`, `staging`, `prod`). * Inject variables anywhere: URL, Headers, Body, or Auth fields using `{{variable_name}}` syntax. * **Dynamic Generators:** Use `{{$uuid}}`, `{{$timestamp}}`, and `{{$randomInt}}` for testing. -### 📥 Migration Ready +### Migration Ready * **Postman Import:** Import existing Postman collections (v2.1) in seconds. * **History Tracking:** Never lose a request. Auto-saves your last 50 executions. @@ -57,7 +57,7 @@ sudo apt update && sudo apt install -y \ libssl-dev curl wget file pkg-config patchelf ``` -### 🏃‍♂️ Run in Development Mode +### Run in Development Mode This starts the Rust shell, compiles the React frontend with HMR, and spawns the Python backend automatically. ```bash @@ -77,7 +77,7 @@ npm run tauri:dev --- -## 📦 Building for Production +## Building for Production We provide a one-shot script that handles the entire pipeline: building the Python sidecar (via PyInstaller), compiling the React assets, and bundling the Tauri installer (`.deb` or `.exe`). @@ -116,4 +116,4 @@ Issues and Pull Requests are welcome! 3. Submit a PR. --- -© 2025 JTechMinds LLC. MIT License. +Built with Care by Jordan Gonzales - © 2025 JTechMinds LLC. MIT License.