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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user