From 5b9ee8d44819533c62a28bc4befddb2570da6a23 Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Tue, 23 Jun 2026 23:55:32 +0300 Subject: [PATCH] feat: Cryptgeon client with AES-GCM client-side encryption Co-Authored-By: Claude Sonnet 4.6 --- openvpncertupdate.py | 52 +++++++++++++++++++++++++++++++++++++++++ tests/test_cryptgeon.py | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/test_cryptgeon.py diff --git a/openvpncertupdate.py b/openvpncertupdate.py index fa5beb7..d3c608e 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -257,6 +257,58 @@ def build_ovpn( return str(out_file.resolve()) +# ============================================================ +# === CRYPTGEON === +# ============================================================ + + +class CryptgeonError(Exception): + pass + + +def create_note(content: str, base_url: str) -> str: + """AES-256-GCM encrypt content, POST to Cryptgeon, return one-time URL. + + Encryption matches the Cryptgeon browser client: + raw_key (32 bytes) → URL fragment (base64url, no padding) + aes_key = SHA-256(raw_key) → AES-256-GCM + payload = list(nonce + ciphertext) + """ + if AESGCM is None: + raise CryptgeonError("cryptography package not installed (pip install cryptography)") + + raw_key = os.urandom(32) + aes_key = hashlib.sha256(raw_key).digest() + nonce = os.urandom(12) + aesgcm = AESGCM(aes_key) + ciphertext = aesgcm.encrypt(nonce, content.encode("utf-8"), None) + + payload = json.dumps({ + "contents": list(nonce + ciphertext), + "views": 1, + "type": "text", + }).encode("utf-8") + + api_url = base_url.rstrip("/") + "/api/notes/" + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + except urllib.error.HTTPError as exc: + raise CryptgeonError(f"Cryptgeon API returned {exc.code}: {exc.reason}") from exc + except Exception as exc: + raise CryptgeonError(f"Cryptgeon request failed: {exc}") from exc + + note_id = body["id"] + key_fragment = base64.urlsafe_b64encode(raw_key).rstrip(b"=").decode() + return f"{base_url.rstrip('/')}/#/note/{note_id}/{key_fragment}" + + # (remaining sections added in later tasks) # ============================================================ diff --git a/tests/test_cryptgeon.py b/tests/test_cryptgeon.py new file mode 100644 index 0000000..b99338b --- /dev/null +++ b/tests/test_cryptgeon.py @@ -0,0 +1,48 @@ +import json +import pytest +from unittest.mock import patch, MagicMock +from openvpncertupdate import create_note, CryptgeonError + + +def mock_response(body: bytes): + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +@patch("openvpncertupdate.urllib.request.urlopen") +def test_returns_url_with_id(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/") + + +@patch("openvpncertupdate.urllib.request.urlopen") +def test_posts_to_correct_endpoint(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] + assert req.full_url == "https://cg.example.com/api/notes/" + body = json.loads(req.data) + assert isinstance(body["contents"], list) + assert body["views"] == 1 + assert body["type"] == "text" + + +@patch("openvpncertupdate.urllib.request.urlopen") +def test_raises_on_http_error(mock_open): + import urllib.error + mock_open.side_effect = urllib.error.HTTPError("", 500, "Error", None, None) + with pytest.raises(CryptgeonError, match="500"): + create_note("secret", "https://cg.example.com") + + +@patch("openvpncertupdate.urllib.request.urlopen") +def test_different_passwords_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