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>
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
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
|
|
|
|
|
|
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_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 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")
|
|
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/"
|
|
|
|
|
|
@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 json.loads(body["meta"]) == {"type": "text"}
|
|
assert body["views"] == 1
|
|
|
|
|
|
@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")
|
|
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_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"
|