feat: Cryptgeon client with AES-GCM client-side encryption

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad Doloman
2026-06-23 23:55:32 +03:00
parent 8a93a1a727
commit 5b9ee8d448
2 changed files with 100 additions and 0 deletions

View File

@@ -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)
# ============================================================

48
tests/test_cryptgeon.py Normal file
View File

@@ -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