Files
odysseus/tests/test_carddav_password_encryption.py
T
Syed Ali Rizvi 57646300a4 fix(security): encrypt CardDAV password at rest in settings.json (#1741)
* fix(security): encrypt CardDAV password at rest in settings.json

CardDAV password was stored in plaintext in data/settings.json, while
other secrets (email, CalDAV) are encrypted using src.secret_storage.

On read (_get_carddav_config): decrypt the password via decrypt().
On write (update_config): encrypt the password via encrypt() before
saving to settings.json.

decrypt() is a no-op on plaintext, so existing deployments upgrade
transparently on the first read after the next config save.

* test: add coverage for CardDAV password encryption

Nine tests covering:
- encrypt-on-save and decrypt-on-read round-trip
- encrypted value is stored with enc: prefix (plaintext absent from file)
- legacy plaintext passthrough
- CARDDAV_PASSWORD env var passthrough (not decrypted)
- empty password / no settings file
- double-save does not corrupt
- encrypt() idempotent on already-encrypted value
2026-06-15 15:58:14 +09:00

171 lines
5.2 KiB
Python

import json
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
def _import_contacts(tmp_path, monkeypatch):
sys.modules.setdefault("core.database", MagicMock())
monkeypatch.setattr(
"routes.contacts_routes.SETTINGS_FILE",
tmp_path / "settings.json",
)
monkeypatch.setattr(
"routes.contacts_routes.DATA_DIR",
tmp_path,
)
monkeypatch.setattr(
"routes.contacts_routes.LOCAL_CONTACTS_FILE",
tmp_path / "contacts.json",
)
sys.modules.pop("src.secret_storage", None)
from src import secret_storage
monkeypatch.setattr(secret_storage, "_KEY_PATH", tmp_path / ".app_key")
monkeypatch.setattr(secret_storage, "_fernet", None)
sys.modules.pop("routes.contacts_routes", None)
from routes import contacts_routes
return contacts_routes
def test_carddav_password_encrypted_at_rest(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
settings = contacts._load_settings()
password = "my-carddav-secret"
from src.secret_storage import encrypt
settings["carddav_password"] = encrypt(password)
contacts._save_settings(settings)
raw_text = (tmp_path / "settings.json").read_text(encoding="utf-8")
assert password not in raw_text
raw = json.loads(raw_text)
assert raw["carddav_password"].startswith("enc:")
cfg = contacts._get_carddav_config()
assert cfg["password"] == password
def test_get_carddav_config_decrypts_encrypted_value(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
from src.secret_storage import encrypt
encrypted = encrypt("super-secret")
settings = {
"carddav_url": "https://carddav.example",
"carddav_username": "u",
"carddav_password": encrypted,
}
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
cfg = contacts._get_carddav_config()
assert cfg["url"] == "https://carddav.example"
assert cfg["username"] == "u"
assert cfg["password"] == "super-secret"
def test_get_carddav_config_plaintext_legacy_passthrough(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
settings = {
"carddav_url": "https://carddav.example",
"carddav_username": "u",
"carddav_password": "legacy-plaintext",
}
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
cfg = contacts._get_carddav_config()
assert cfg["password"] == "legacy-plaintext"
def test_get_carddav_config_env_var_passthrough(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
monkeypatch.setenv("CARDDAV_PASSWORD", "env-pass")
settings = {
"carddav_url": "https://carddav.example",
"carddav_username": "u",
}
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
cfg = contacts._get_carddav_config()
assert cfg["password"] == "env-pass"
def test_get_carddav_config_env_var_not_decrypted(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
monkeypatch.setenv("CARDDAV_PASSWORD", "env:plain-value-not-encrypted")
settings = {
"carddav_url": "https://carddav.example",
"carddav_username": "u",
}
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
cfg = contacts._get_carddav_config()
assert cfg["password"] == "env:plain-value-not-encrypted"
def test_get_carddav_config_empty_password(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
settings = {
"carddav_url": "https://carddav.example",
"carddav_username": "u",
}
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
cfg = contacts._get_carddav_config()
assert cfg["password"] == ""
def test_get_carddav_config_no_settings_file(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
cfg = contacts._get_carddav_config()
assert cfg["password"] == ""
assert cfg["url"] == ""
def test_double_save_encrypted_value_not_corrupted(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
from src.secret_storage import encrypt
password = "persistent-secret"
encrypted = encrypt(password)
settings = {"carddav_password": encrypted}
contacts._save_settings(settings)
settings2 = contacts._load_settings()
contacts._save_settings(settings2)
cfg = contacts._get_carddav_config()
assert cfg["password"] == password
def test_double_save_re_encrypts_already_encrypted_is_noop(tmp_path, monkeypatch):
contacts = _import_contacts(tmp_path, monkeypatch)
from src.secret_storage import encrypt
password = "another-secret"
settings = contacts._load_settings()
settings["carddav_password"] = encrypt(password)
contacts._save_settings(settings)
settings2 = contacts._load_settings()
settings2["carddav_password"] = encrypt(settings2["carddav_password"])
contacts._save_settings(settings2)
raw = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8"))
assert raw["carddav_password"].startswith("enc:")
cfg = contacts._get_carddav_config()
assert cfg["password"] == password