diff --git a/credential_crypto.py b/credential_crypto.py index 9e31f2e..d1813b6 100644 --- a/credential_crypto.py +++ b/credential_crypto.py @@ -132,9 +132,8 @@ def encrypt_secret(value: str | None, db_path: Path) -> str: if not value or is_encrypted(value): return value or "" raw = value.encode("utf-8") - if os.name == "nt" and not os.environ.get("CB_GATEWAY_MASTER_KEY"): - protected = _dpapi_encrypt(raw) - return _DPAPI_PREFIX + base64.urlsafe_b64encode(protected).decode("ascii") + # Always Fernet (MASTER_KEY or sidecar key file). Linux Docker cannot open + # Windows DPAPI rows; existing enc:v1:dpapi: values still decrypt on Windows. token = _get_fernet(db_path).encrypt(raw).decode("ascii") return _FERNET_PREFIX + token diff --git a/docker-compose.yml b/docker-compose.yml index ed5ddc5..448430f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,9 @@ services: - CB_AUTH_DIR=/auth - CB_CONTAINER_AUTH_DIR=/auth - CB_DOCKER=1 - # Topology: Windows host + Linux container cannot decrypt DPAPI blobs. - # Set CB_GATEWAY_MASTER_KEY when the SQLite file is shared across OS. + # Official QClaw/QwenWork login files are Windows DPAPI; import them on + # the host. SQLite tokens use the sidecar key file (or MASTER_KEY) so + # Linux can use already-imported accounts. WorkBuddy still mounts /auth. healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health', timeout=3)"] interval: 30s diff --git a/tests/test_core.py b/tests/test_core.py index ab4c81e..f3475b6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -300,6 +300,21 @@ def isolated_db(tmp_path, monkeypatch): credential_crypto.reset_cache() +def test_encrypt_without_master_key_uses_fernet_key_file(tmp_path, monkeypatch): + path = tmp_path / "gateway.db" + monkeypatch.setattr(db, "DB_PATH", path) + monkeypatch.delenv("CB_GATEWAY_MASTER_KEY", raising=False) + credential_crypto.reset_cache() + db.init_db() + account_id = db.add_account({"name": "portable", "access_token": "access-secret"}) + with sqlite3.connect(path) as conn: + raw = conn.execute("SELECT access_token FROM accounts WHERE id=?", (account_id,)).fetchone()[0] + assert raw.startswith("enc:v1:fernet:") + assert "access-secret" not in raw + assert db.get_account(account_id)["access_token"] == "access-secret" + credential_crypto.reset_cache() + + def test_account_credentials_are_encrypted_at_rest(isolated_db): account_id = db.add_account( {