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

@@ -10,7 +10,7 @@ import email.encoders
import email.mime.base
import email.mime.multipart
import email.mime.text
import hashlib
import getpass
import json
import os
import secrets
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from enum import Enum, auto
from pathlib import Path
from typing import Optional
from typing import Callable, Optional
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -36,9 +36,18 @@ except ImportError: # pragma: no cover
# SETTINGS — edit these for your environment
# ============================================================
CONFIG_PATH = "" # explicit path to an external .conf file overriding the settings
# below (same syntax as this block). Overridden by --config.
# Empty = look for "<this-script-path>.conf" next to the script;
# if that default file is absent it is skipped silently. An
# explicit CONFIG_PATH or --config that doesn't exist is an error.
EASYRSA_DIR = "/etc/easy-rsa"
EASYRSA_PKI_DIR = "/etc/easy-rsa/pki"
CA_PASSPHRASE = "" # empty string = no passphrase
CA_PASSPHRASE = "" # "" = auto-detect & prompt if the CA key is encrypted
# "!empty" = CA key is unencrypted, never check/prompt
# "!ask" = always prompt, skip detection
# any other value = used as the literal passphrase
OVPN_TEMPLATE_PATH = "./template.ovpn"
CONFIG_NAME = "client.ovpn"
@@ -62,6 +71,52 @@ SMTP_TLS = "starttls" # "starttls" | "ssl" | "" (plain)
DAYS_PAST = 30
DAYS_AHEAD = 14
# Names an external .conf file is allowed to override — keep in sync with the
# SETTINGS block above. Deliberately excludes CONFIG_PATH itself.
_OVERRIDABLE_SETTINGS = frozenset({
"EASYRSA_DIR", "EASYRSA_PKI_DIR", "CA_PASSPHRASE",
"OVPN_TEMPLATE_PATH", "CONFIG_NAME", "VPN_CONFIGS_DIR",
"CRL_DEST_PATH",
"CRYPTGEON_URL",
"MAIL_FROM", "MAIL_SUBJECT", "EMAIL_TEMPLATE_PATH", "MAIL_BINARY",
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_TLS",
"DAYS_PAST", "DAYS_AHEAD",
})
# ============================================================
# === SETTINGS OVERRIDE ===
# ============================================================
class ConfigError(Exception):
pass
def load_settings_overrides(
cli_config: Optional[str], configured_path: str, script_path: str
) -> dict:
"""Load SETTINGS overrides from an external .conf file (same Python syntax
as the SETTINGS block above — it's executed, so only run trusted files).
Resolution order: --config CLI flag > CONFIG_PATH setting > "<script_path>.conf"
next to this script. A path from the CLI flag or CONFIG_PATH is explicit: a
missing file raises ConfigError. The default "<script_path>.conf" path is
optional: a missing file returns {} silently.
"""
explicit = bool(cli_config or configured_path)
path = cli_config or configured_path or f"{script_path}.conf"
if not os.path.isfile(path):
if explicit:
raise ConfigError(f"config file not found: {path}")
return {}
namespace: dict = {}
exec(compile(Path(path).read_text(), path, "exec"), namespace)
return {name: value for name, value in namespace.items() if name in _OVERRIDABLE_SETTINGS}
# ============================================================
# === PKI ===
# ============================================================
@@ -247,6 +302,37 @@ def copy_crl(pki_dir: str, dest_path: str) -> None:
os.chmod(dest_path, 0o644)
def is_ca_key_encrypted(pki_dir: str) -> bool:
"""Best-effort check of the CA private key's PEM header. Missing/unreadable
key => False (let the normal EasyRSA flow surface any real problem)."""
try:
header = Path(f"{pki_dir}/private/ca.key").read_text()
except OSError:
return False
return "ENCRYPTED" in header
def resolve_ca_passphrase(
configured: str, pki_dir: str, prompt: Callable[[str], str] = getpass.getpass
) -> str:
"""Resolve the SETTINGS CA_PASSPHRASE sentinel into an actual passphrase.
"" → auto-detect: prompt only if the CA key is actually encrypted
"!empty" → explicitly unencrypted; never check, never prompt
"!ask" → always prompt, without checking
other → used as the literal passphrase
"""
if configured == "!empty":
return ""
if configured == "!ask":
return prompt("CA passphrase: ")
if configured == "":
if is_ca_key_encrypted(pki_dir):
return prompt("CA key is encrypted; enter CA passphrase: ")
return ""
return configured
# ============================================================
# === METADATA ===
# ============================================================
@@ -318,25 +404,28 @@ class CryptgeonError(Exception):
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)
Encryption matches the Cryptgeon browser client (occulto's AES.encrypt +
API.create):
key (32 bytes, used directly, no derivation) → URL fragment (hex)
contents = base64(b"AES-GCM") + "--" + base64(nonce) + "--" + base64(ciphertext)
meta = JSON string '{"type": "text"}'
"""
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)
key = os.urandom(32)
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, content.encode("utf-8"), None)
contents = "--".join(
base64.b64encode(part).decode("ascii") for part in (b"AES-GCM", nonce, ciphertext)
)
payload = json.dumps({
"contents": base64.b64encode(nonce + ciphertext).decode("ascii"),
"meta": "",
"contents": contents,
"meta": json.dumps({"type": "text"}),
"views": 1,
"type": "text",
}).encode("utf-8")
api_url = base_url.rstrip("/") + "/api/notes/"
@@ -354,9 +443,8 @@ def create_note(content: str, base_url: str) -> str:
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}"
note_id = body["id"]
return f"{base_url.rstrip('/')}/note/{note_id}#{key.hex()}"
# ============================================================
@@ -1296,6 +1384,9 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("--show-eml", action="store_true",
help="Print the generated email as base64-encoded .eml to stdout "
"(implies --no-send-email unless --send-email is also given)")
parser.add_argument("--config", metavar="PATH",
help="External .conf file overriding SETTINGS (overrides CONFIG_PATH; "
"a missing file here is an error)")
return parser
@@ -1308,6 +1399,16 @@ def main() -> None:
parser = _build_parser()
args = parser.parse_args()
try:
overrides = load_settings_overrides(args.config, CONFIG_PATH, __file__)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
globals().update(overrides)
global CA_PASSPHRASE
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)