fix Cryptgeon protocol and add CA passphrase / external config resolution

Cryptgeon's real protocol (verified against upstream occulto/frontend/backend
source) never matched what create_note() sent: it used a SHA-256-derived key
instead of the raw one, a single-blob ciphertext instead of the delimited
AES-GCM--nonce--ciphertext format, an empty meta field instead of a JSON
string, and a hash-bang URL instead of the real /note/<id>#<key> route -
notes uploaded fine but were undecryptable in the browser.

Also adds CA_PASSPHRASE auto-detection/prompting ("" auto-detects an
encrypted CA key and prompts, "!empty"/"!ask" opt out of/force the prompt)
and an external .conf file (--config / CONFIG_PATH / <script>.conf) that can
override the SETTINGS block without editing the script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-07-06 12:58:21 +03:00
parent 24cae3de6a
commit bd17cdd68d
6 changed files with 368 additions and 27 deletions

View File

@@ -200,6 +200,65 @@ def test_main_no_args_launches_tui(monkeypatch):
app.run.assert_called_once_with()
def test_main_resolves_ca_passphrase_before_dispatch(monkeypatch, tmp_path):
"""The configured CA_PASSPHRASE sentinel must be resolved to an actual
passphrase before any EasyRSA call, and that resolved value (not the
sentinel) must be what reaches them."""
monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl"])
monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!ask")
monkeypatch.setattr("openvpncertupdate.EASYRSA_DIR", "/er")
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", str(tmp_path))
mock_gen_crl = MagicMock()
monkeypatch.setattr("openvpncertupdate.gen_crl", mock_gen_crl)
monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock())
mock_resolve = MagicMock(return_value="typed-pass")
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
main()
mock_resolve.assert_called_once_with("!ask", str(tmp_path))
def test_main_applies_config_overrides_before_dispatch(monkeypatch, tmp_path):
"""Overrides returned by load_settings_overrides() must land in the module
globals before EasyRSA calls, so a config file can redirect EASYRSA_PKI_DIR etc."""
monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl"])
monkeypatch.setattr("openvpncertupdate.EASYRSA_DIR", "/er")
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/original/pki")
monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!empty")
mock_gen_crl = MagicMock()
monkeypatch.setattr("openvpncertupdate.gen_crl", mock_gen_crl)
monkeypatch.setattr("openvpncertupdate.copy_crl", MagicMock())
mock_load = MagicMock(return_value={"EASYRSA_PKI_DIR": "/overridden/pki"})
monkeypatch.setattr("openvpncertupdate.load_settings_overrides", mock_load)
main()
mock_gen_crl.assert_called_once_with("/er", "/overridden/pki", "")
def test_main_passes_config_flag_to_load_overrides(monkeypatch):
monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl", "--config", "/explicit/path.conf"])
monkeypatch.setattr("openvpncertupdate.CA_PASSPHRASE", "!empty")
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
mock_load = MagicMock(return_value={})
monkeypatch.setattr("openvpncertupdate.load_settings_overrides", mock_load)
main()
assert mock_load.call_args[0][0] == "/explicit/path.conf"
def test_main_exits_with_error_when_explicit_config_missing(monkeypatch, capsys):
monkeypatch.setattr(sys, "argv", ["prog", "--gen-crl", "--config", "/does/not/exist.conf"])
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code != 0
assert "/does/not/exist.conf" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# --send-email / --no-send-email
# ---------------------------------------------------------------------------

62
tests/test_config_file.py Normal file
View File

@@ -0,0 +1,62 @@
import pytest
from openvpncertupdate import load_settings_overrides, ConfigError
def _script_path(tmp_path):
return str(tmp_path / "openvpncertupdate.py")
def test_default_path_missing_returns_empty_silently(tmp_path):
overrides = load_settings_overrides(None, "", _script_path(tmp_path))
assert overrides == {}
def test_default_path_found_loads_overrides(tmp_path):
conf = tmp_path / "openvpncertupdate.py.conf"
conf.write_text('EASYRSA_DIR = "/custom/easyrsa"\n')
overrides = load_settings_overrides(None, "", _script_path(tmp_path))
assert overrides == {"EASYRSA_DIR": "/custom/easyrsa"}
def test_explicit_cli_path_missing_raises(tmp_path):
missing = str(tmp_path / "missing.conf")
with pytest.raises(ConfigError, match=missing):
load_settings_overrides(missing, "", _script_path(tmp_path))
def test_explicit_configured_path_missing_raises(tmp_path):
missing = str(tmp_path / "missing2.conf")
with pytest.raises(ConfigError, match=missing):
load_settings_overrides(None, missing, _script_path(tmp_path))
def test_cli_path_takes_priority_over_configured_path(tmp_path):
cli_conf = tmp_path / "from_cli.conf"
cli_conf.write_text('EASYRSA_DIR = "/from/cli"\n')
configured_conf = tmp_path / "from_configured.conf"
configured_conf.write_text('EASYRSA_DIR = "/from/configured"\n')
overrides = load_settings_overrides(str(cli_conf), str(configured_conf), _script_path(tmp_path))
assert overrides == {"EASYRSA_DIR": "/from/cli"}
def test_unrecognized_and_config_path_names_are_filtered_out(tmp_path):
conf = tmp_path / "openvpncertupdate.py.conf"
conf.write_text(
'EASYRSA_DIR = "/custom/easyrsa"\n'
'SOME_RANDOM_NAME = "y"\n'
'CONFIG_PATH = "/should/not/apply"\n'
)
overrides = load_settings_overrides(None, "", _script_path(tmp_path))
assert overrides == {"EASYRSA_DIR": "/custom/easyrsa"}
def test_comments_and_non_string_types_supported(tmp_path):
conf = tmp_path / "openvpncertupdate.py.conf"
conf.write_text(
"# a comment\n"
"SMTP_PORT = 2525\n"
'CA_PASSPHRASE = "" # inline comment\n'
)
overrides = load_settings_overrides(None, "", _script_path(tmp_path))
assert overrides == {"SMTP_PORT": 2525, "CA_PASSPHRASE": ""}

View File

@@ -1,6 +1,8 @@
import base64
import json
import pytest
from unittest.mock import patch, MagicMock
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from openvpncertupdate import create_note, CryptgeonError
@@ -13,11 +15,13 @@ def mock_response(body: bytes):
@patch("openvpncertupdate.urllib.request.urlopen")
def test_returns_url_with_id(mock_open):
def test_url_uses_path_based_note_route_with_hex_key_fragment(mock_open):
mock_open.return_value = mock_response(b'{"id":"abc-123"}')
url = create_note("secret", "https://cg.example.com")
assert "abc-123" in url
assert url.startswith("https://cg.example.com/#/note/abc-123/")
assert url.startswith("https://cg.example.com/note/abc-123#")
fragment = url.split("#", 1)[1]
assert len(fragment) == 64
bytes.fromhex(fragment) # must be valid hex, not base64url
@patch("openvpncertupdate.urllib.request.urlopen")
@@ -26,11 +30,29 @@ def test_posts_to_correct_endpoint(mock_open):
create_note("hello", "https://cg.example.com")
req = mock_open.call_args[0][0]
assert req.full_url == "https://cg.example.com/api/notes/"
@patch("openvpncertupdate.urllib.request.urlopen")
def test_meta_is_json_string_describing_text_note(mock_open):
mock_open.return_value = mock_response(b'{"id":"x"}')
create_note("hello", "https://cg.example.com")
req = mock_open.call_args[0][0]
body = json.loads(req.data)
assert isinstance(body["contents"], str)
assert body["meta"] == ""
assert json.loads(body["meta"]) == {"type": "text"}
assert body["views"] == 1
assert body["type"] == "text"
@patch("openvpncertupdate.urllib.request.urlopen")
def test_contents_is_three_part_dash_delimited_base64(mock_open):
mock_open.return_value = mock_response(b'{"id":"x"}')
create_note("hello", "https://cg.example.com")
req = mock_open.call_args[0][0]
body = json.loads(req.data)
parts = body["contents"].split("--")
assert len(parts) == 3
alg, nonce, ciphertext = (base64.b64decode(p) for p in parts)
assert alg == b"AES-GCM"
assert len(nonce) == 12
@patch("openvpncertupdate.urllib.request.urlopen")
@@ -42,8 +64,28 @@ def test_raises_on_http_error(mock_open):
@patch("openvpncertupdate.urllib.request.urlopen")
def test_different_passwords_give_different_urls(mock_open):
def test_different_contents_give_different_urls(mock_open):
mock_open.return_value = mock_response(b'{"id":"same"}')
url1 = create_note("pass1", "https://cg.example.com")
url2 = create_note("pass2", "https://cg.example.com")
assert url1 != url2
@patch("openvpncertupdate.urllib.request.urlopen")
def test_decrypts_like_the_browser_client(mock_open):
"""Reproduces the note/[id] view: key from the URL fragment must be the
exact key used to encrypt (no extra hashing step), or GCM auth fails."""
mock_open.return_value = mock_response(b'{"id":"abc"}')
url = create_note("my secret password", "https://cg.example.com")
req = mock_open.call_args[0][0]
body = json.loads(req.data)
key_hex = url.split("#", 1)[1]
key = bytes.fromhex(key_hex)
alg_b64, nonce_b64, ciphertext_b64 = body["contents"].split("--")
nonce = base64.b64decode(nonce_b64)
ciphertext = base64.b64decode(ciphertext_b64)
plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
assert plaintext == b"my secret password"

View File

@@ -2,6 +2,7 @@ import pytest
from unittest.mock import patch, MagicMock
from openvpncertupdate import (
revoke_issued, build_client_full, gen_crl, copy_crl, EasyRSAError,
is_ca_key_encrypted, resolve_ca_passphrase,
)
@@ -65,3 +66,75 @@ def test_copy_crl_copies_and_chmods(mock_chmod, mock_copy):
copy_crl("/pki", "/etc/openvpn/crl.pem")
mock_copy.assert_called_once_with("/pki/crl.pem", "/etc/openvpn/crl.pem")
mock_chmod.assert_called_once_with("/etc/openvpn/crl.pem", 0o644)
# ---------------------------------------------------------------------------
# is_ca_key_encrypted
# ---------------------------------------------------------------------------
def _write_ca_key(pki_dir, content):
private = pki_dir / "private"
private.mkdir(parents=True, exist_ok=True)
(private / "ca.key").write_text(content)
def test_is_ca_key_encrypted_true_for_pkcs8_encrypted_header(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is True
def test_is_ca_key_encrypted_true_for_legacy_proc_type_header(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,...\n\nAAA\n-----END RSA PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is True
def test_is_ca_key_encrypted_false_for_plain_key(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
assert is_ca_key_encrypted(str(tmp_path)) is False
def test_is_ca_key_encrypted_false_when_key_missing(tmp_path):
assert is_ca_key_encrypted(str(tmp_path)) is False
# ---------------------------------------------------------------------------
# resolve_ca_passphrase
# ---------------------------------------------------------------------------
def test_resolve_empty_string_prompts_when_ca_is_encrypted(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
prompt = MagicMock(return_value="typed-pass")
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
assert result == "typed-pass"
prompt.assert_called_once()
def test_resolve_empty_string_skips_prompt_when_ca_not_encrypted(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n")
prompt = MagicMock()
result = resolve_ca_passphrase("", str(tmp_path), prompt=prompt)
assert result == ""
prompt.assert_not_called()
def test_resolve_empty_sentinel_never_checks_or_prompts(tmp_path):
_write_ca_key(tmp_path, "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAA\n-----END ENCRYPTED PRIVATE KEY-----\n")
prompt = MagicMock()
result = resolve_ca_passphrase("!empty", str(tmp_path), prompt=prompt)
assert result == ""
prompt.assert_not_called()
def test_resolve_ask_sentinel_always_prompts_without_checking(tmp_path):
# no ca.key on disk at all -- must not be checked/read for "!ask"
prompt = MagicMock(return_value="typed-pass")
result = resolve_ca_passphrase("!ask", str(tmp_path), prompt=prompt)
assert result == "typed-pass"
prompt.assert_called_once()
def test_resolve_literal_passphrase_passed_through_unchanged(tmp_path):
prompt = MagicMock()
result = resolve_ca_passphrase("mysecret", str(tmp_path), prompt=prompt)
assert result == "mysecret"
prompt.assert_not_called()