Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 20 additions & 3 deletions backend/app/core/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
138 changes: 124 additions & 14 deletions backend/app/core/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CryptoError,
_derive_key,
_parse_encrypted,
is_encrypted_string,
KDF_NAME,
KEY_LEN,
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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"):
Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/test_crypto_master.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
23 changes: 13 additions & 10 deletions frontend/src/components/workspace/WorkspacePassphraseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const WorkspacePassphraseModal = () => {
isLocked,
legacyMode,
hasVault,
hasCiphertext,
setUnlocked,
modalOpen,
closeModal,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -206,15 +217,7 @@ export const WorkspacePassphraseModal = () => {
</button>
)}
</div>
<div className="text-xs text-muted-foreground">
{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.'}
</div>
<div className="text-xs text-muted-foreground">{statusMessage}</div>
{error && <div className="text-xs text-destructive bg-destructive/10 px-2 py-1 rounded">{error}</div>}
<div className="space-y-4">
<div className="space-y-1">
Expand Down Expand Up @@ -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
</button>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useWorkspaceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading