Restructure backend into microservices, add envelope encryption, DM files, and message editing

This commit is contained in:
2026-01-10 14:59:31 +03:00
Unverified
parent 1f706eaa34
commit fd4c00057c
74 changed files with 6647 additions and 820 deletions
@@ -0,0 +1,523 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
from crypto import decrypt_file_bytes_from_meta, decrypt_message, load_compliance_private_key
from report_assets import write_assets
from utils import guess_is_image, html_escape, href_escape, parse_message_plaintext, safe_filename
@dataclass(frozen=True)
class Attachment:
filename: str
output_rel: str
size_bytes: int
is_image: bool
@dataclass(frozen=True)
class DecryptedMessage:
message_id: int
sender_id: int
sender_label: str
recipient_id: int
recipient_label: str
timestamp: str
text: str
attachments: List[Attachment]
edit_history: List['DecryptedEdit'] = None
def __post_init__(self):
if self.edit_history is None:
object.__setattr__(self, 'edit_history', [])
@dataclass(frozen=True)
class DecryptedEdit:
edit_id: int
edited_at: str
edited_by_user_id: int
edited_by_username: str
previous_text: str
def _load_manifest(bundle_dir: Path) -> Dict[str, Any]:
manifest_path = bundle_dir / "bundle.json"
if not manifest_path.exists():
raise RuntimeError(f"bundle.json not found in: {bundle_dir}")
return json.loads(manifest_path.read_text(encoding="utf-8"))
def _parse_timestamp_day(ts: str) -> str:
return (ts or "")[:10] if isinstance(ts, str) and len(ts) >= 10 else ""
def _format_ts(ts: str) -> str:
raw = (ts or "").strip()
if not raw:
return ""
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return dt.strftime("%d.%m.%Y %H:%M:%S")
except Exception:
return raw
def _format_time(ts: str) -> str:
raw = (ts or "").strip()
if not raw:
return ""
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return dt.strftime("%H:%M:%S")
except Exception:
return raw
def _format_day(ts: str) -> str:
raw = (ts or "").strip()
if not raw:
return ""
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return dt.strftime("%d.%m.%Y")
except Exception:
return _parse_timestamp_day(raw)
def _conversation_key(sender_id: int, recipient_id: int) -> Tuple[int, int]:
a, b = int(sender_id), int(recipient_id)
return (a, b) if a < b else (b, a)
def _best_username(username: str | None, display_name: str | None, user_id: int) -> str:
u = (username or "").strip()
if u:
return u
d = (display_name or "").strip()
if d:
return d
return f"user{user_id}"
def _format_user_label(username: str | None, display_name: str | None, user_id: int) -> str:
return f"{_best_username(username, display_name, user_id)} (#{user_id})"
def _format_bytes(n: int) -> str:
try:
size = float(int(n))
except Exception:
return f"{n} B"
units = ["B", "KB", "MB", "GB", "TB"]
unit = units[0]
for u in units:
unit = u
if size < 1024.0 or u == units[-1]:
break
size /= 1024.0
if unit == "B":
return f"{int(size)} B"
if size >= 100:
return f"{size:.0f} {unit}"
if size >= 10:
return f"{size:.1f} {unit}"
return f"{size:.2f} {unit}"
def _render_report(
out_dir: Path,
conversations: Dict[Tuple[int, int], List[DecryptedMessage]],
conversation_names: Dict[Tuple[int, int], Tuple[str, str]],
css_href: str,
js_src: str,
) -> None:
total_messages = sum(len(v) for v in conversations.values())
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
parts: list[str] = []
parts.append("<!doctype html>")
parts.append("<html lang=\"en\">")
parts.append("<head>")
parts.append("<meta charset=\"utf-8\"/>")
parts.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
parts.append("<title>FromChat Compliance Bundle</title>")
parts.append(f"<link rel=\"stylesheet\" href=\"{html_escape(css_href)}\"/>")
parts.append(f"<script src=\"{html_escape(js_src)}\" defer></script>")
parts.append("</head>")
parts.append("<body>")
parts.append("<div class=\"topbar\">")
parts.append("<div class=\"topbar-inner\">")
parts.append("<div class=\"brand\">")
parts.append("<div class=\"brand-title\">FromChat compliance bundle</div>")
parts.append(f"<div class=\"brand-subtitle\">Decrypted at: {html_escape(now)} • Messages: {total_messages}</div>")
parts.append("</div>")
parts.append("<div class=\"tools\">")
parts.append("<input id=\"searchInput\" class=\"search\" placeholder=\"Search messages / filenames / user ids\"/>")
parts.append("<div id=\"filterHint\" class=\"hint\">Type to filter by text, user id, filename</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("<div class=\"wrap\">")
for (left_id, right_id), msgs in sorted(conversations.items(), key=lambda x: x[0]):
msgs_sorted = sorted(msgs, key=lambda m: (m.timestamp, m.message_id))
left_name, right_name = conversation_names.get((left_id, right_id), (str(left_id), str(right_id)))
conv_title = f"Conversation: {left_name}{right_name}"
conv_sub = f"{len(msgs_sorted)} message(s)"
parts.append(f"<div class=\"conversation\" data-conv=\"{left_id}-{right_id}\">")
parts.append("<div class=\"conv-header\">")
parts.append("<div class=\"conv-title\">")
parts.append(f"<div class=\"line1\">{html_escape(conv_title)}</div>")
parts.append(f"<div class=\"line2\">{html_escape(conv_sub)}</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("<div class=\"messages\">")
current_day = ""
for m in msgs_sorted:
day = _format_day(m.timestamp)
if day and day != current_day:
current_day = day
parts.append("<div class=\"day\"><span>")
parts.append(html_escape(day))
parts.append("</span></div>")
searchable = (
f"{m.message_id} {m.sender_id} {m.sender_label} {m.recipient_id} {m.recipient_label} {m.timestamp} {m.text} "
+ " ".join(a.filename for a in m.attachments)
)
# Create container for message with edit history
parts.append(f"<div class=\"message-container\" data-search=\"{html_escape(searchable)}\">")
# Edit history tabs (vertical on the left)
if m.edit_history:
parts.append("<div class=\"edit-tabs-vertical\">")
# Add current version as "Latest" (most recent, at top)
latest_timestamp = max(edit.edited_at for edit in m.edit_history)
latest_datetime = _format_day(latest_timestamp) + " " + _format_time(latest_timestamp)
parts.append(f"<div class=\"tab-vertical active\" data-version=\"latest\" data-message-id=\"{m.message_id}\" data-timestamp=\"{latest_timestamp}\">")
parts.append("<div class=\"tab-label-vertical\">Latest</div>")
parts.append(f"<div class=\"tab-time-vertical\" data-timestamp=\"{latest_timestamp}\">{html_escape(latest_datetime)}</div>")
parts.append("</div>")
# Add edit history tabs in reverse chronological order (most recent first)
for i, edit in enumerate(reversed(m.edit_history)):
version_num = len(m.edit_history) - i
tab_label = f"v{version_num}"
# Each version tab shows when that version was created
tab_timestamp = m.timestamp if version_num == 1 else m.edit_history[version_num-2].edited_at
tab_datetime = _format_day(tab_timestamp) + " " + _format_time(tab_timestamp)
parts.append(f"<div class=\"tab-vertical\" data-version=\"edit-{edit.edit_id}\" data-message-id=\"{m.message_id}\" data-timestamp=\"{tab_timestamp}\">")
parts.append(f"<div class=\"tab-label-vertical\">{html_escape(tab_label)}</div>")
parts.append(f"<div class=\"tab-time-vertical\" data-timestamp=\"{tab_timestamp}\">{html_escape(tab_datetime)}</div>")
parts.append("</div>")
parts.append("</div>") # end tabs
# Message bubble container
parts.append("<div class=\"bubble-area\">")
# Current version bubble
parts.append(f"<div class=\"bubble active\" data-version=\"latest\" data-message-id=\"{m.message_id}\">")
parts.append("<div class=\"bubble-header\">")
parts.append(
f"<div class=\"who\"><strong>{html_escape(m.sender_label)}</strong> → {html_escape(m.recipient_label)}</div>"
)
parts.append("</div>")
parts.append(f"<div class=\"text\">{html_escape(m.text)}</div>")
if m.attachments:
parts.append("<div class=\"attachments\">")
for a in m.attachments:
rel = href_escape(a.output_rel)
parts.append("<div class=\"att\">")
parts.append(f"<div class=\"att-name\">{html_escape(a.filename)}</div>")
if a.is_image:
parts.append(
f"<a href=\"{html_escape(rel)}\"><img class=\"thumb\" src=\"{html_escape(rel)}\" alt=\"{html_escape(a.filename)}\"/></a>"
)
parts.append("<div class=\"att-actions\">")
parts.append(f"<a href=\"{html_escape(rel)}\" download>Download</a>")
parts.append(f"<span class=\"att-size\">{html_escape(_format_bytes(a.size_bytes))}</span>")
parts.append("</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("<div class=\"msg-meta\">")
parts.append(f"<div class=\"msg-meta-left\">#{m.message_id}</div>")
latest_edit_time = max(edit.edited_at for edit in m.edit_history) if m.edit_history else m.timestamp
parts.append(f"<div class=\"msg-meta-right\" data-timestamp=\"{latest_edit_time}\">{html_escape(_format_time(latest_edit_time))}</div>")
parts.append("</div>")
parts.append("</div>")
# Edit history bubbles
for i, edit in enumerate(m.edit_history):
version_num = i + 1
# Calculate the timestamp when this version was active
bubble_timestamp = m.timestamp if i == 0 else m.edit_history[i-1].edited_at
parts.append(f"<div class=\"bubble\" data-version=\"edit-{edit.edit_id}\" data-message-id=\"{m.message_id}\">")
parts.append("<div class=\"bubble-header\">")
parts.append(
f"<div class=\"who\"><strong>{html_escape(m.sender_label)}</strong> → {html_escape(m.recipient_label)}</div>"
)
parts.append("</div>")
parts.append(f"<div class=\"text\">{html_escape(edit.previous_text)}</div>")
if m.attachments:
parts.append("<div class=\"attachments\">")
for a in m.attachments:
rel = href_escape(a.output_rel)
parts.append("<div class=\"att\">")
parts.append(f"<div class=\"att-name\">{html_escape(a.filename)}</div>")
if a.is_image:
parts.append(
f"<a href=\"{html_escape(rel)}\"><img class=\"thumb\" src=\"{html_escape(rel)}\" alt=\"{html_escape(a.filename)}\"/></a>"
)
parts.append("<div class=\"att-actions\">")
parts.append(f"<a href=\"{html_escape(rel)}\" download>Download</a>")
parts.append(f"<span class=\"att-size\">{html_escape(_format_bytes(a.size_bytes))}</span>")
parts.append("</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("<div class=\"msg-meta\">")
parts.append(f"<div class=\"msg-meta-left\">#{m.message_id}</div>")
parts.append(f"<div class=\"msg-meta-right\" data-timestamp=\"{bubble_timestamp}\">{html_escape(_format_time(bubble_timestamp))}</div>")
parts.append("</div>")
parts.append("</div>")
parts.append("</div>") # end bubble-area
parts.append("</div>") # end message-container
parts.append("</div>")
parts.append("</div>")
parts.append("<div class=\"footer\">⚠️ This content has been accessed for compliance purposes. Handle and destroy according to policy.</div>")
parts.append("</div>")
parts.append("</body></html>")
(out_dir / "index.html").write_text("\n".join(parts), encoding="utf-8")
def decrypt_bundle(bundle_dir: str, output_dir: str, *, key_file: str = "compliance_keypair.txt") -> str:
bundle_path = Path(bundle_dir).resolve()
out_path = Path(output_dir).resolve()
out_path.mkdir(parents=True, exist_ok=True)
manifest = _load_manifest(bundle_path)
messages = manifest.get("messages") if isinstance(manifest, dict) else None
if not isinstance(messages, list) or not messages:
raise RuntimeError("bundle.json has no messages")
compliance_private_key = load_compliance_private_key(key_file=key_file)
compliance_public_key = compliance_private_key.public_key()
conversations: Dict[Tuple[int, int], List[DecryptedMessage]] = {}
conversation_names: Dict[Tuple[int, int], Tuple[str, str]] = {}
for entry in messages:
if not isinstance(entry, dict):
continue
message_id = entry.get("message_id")
msg_file = entry.get("message_data_file")
if not isinstance(message_id, int) or not isinstance(msg_file, str):
continue
msg_abs = bundle_path / msg_file
message_data = json.loads(msg_abs.read_text(encoding="utf-8"))
if not isinstance(message_data, dict):
continue
plaintext = decrypt_message(message_data, compliance_private_key, compliance_public_key)
parsed = parse_message_plaintext(plaintext)
text = parsed.get("text") or plaintext
msg_out_dir = out_path / "messages" / str(message_id)
msg_files_out_dir = msg_out_dir / "files"
msg_files_out_dir.mkdir(parents=True, exist_ok=True)
(msg_out_dir / "message.decrypted.txt").write_text(plaintext, encoding="utf-8")
(msg_out_dir / "message.decrypted.json").write_text(
json.dumps(
{
"message_id": message_id,
"sender_id": message_data.get("sender_id"),
"recipient_id": message_data.get("recipient_id"),
"timestamp": message_data.get("timestamp"),
"plaintext": plaintext,
"parsed": parsed,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
sender_id = int(message_data.get("sender_id") or 0)
recipient_id = int(message_data.get("recipient_id") or 0)
ts = str(message_data.get("timestamp") or "")
sender_username = entry.get("sender_username") if isinstance(entry.get("sender_username"), str) else None
sender_display_name = entry.get("sender_display_name") if isinstance(entry.get("sender_display_name"), str) else None
recipient_username = entry.get("recipient_username") if isinstance(entry.get("recipient_username"), str) else None
recipient_display_name = (
entry.get("recipient_display_name") if isinstance(entry.get("recipient_display_name"), str) else None
)
sender_label = _format_user_label(sender_username, sender_display_name, sender_id)
recipient_label = _format_user_label(recipient_username, recipient_display_name, recipient_id)
# Process edit history
edit_history: list[DecryptedEdit] = []
entry_edits = entry.get("edit_history")
if isinstance(entry_edits, list):
for edit_entry in entry_edits:
if not isinstance(edit_entry, dict):
continue
edit_data_file = edit_entry.get("edit_data_file")
if not isinstance(edit_data_file, str):
continue
edit_abs = bundle_path / edit_data_file
if not edit_abs.exists():
continue
edit_data = json.loads(edit_abs.read_text(encoding="utf-8"))
if not isinstance(edit_data, dict):
continue
# Decrypt the previous version of the message
previous_message_data = {
"sender_id": sender_id,
"recipient_id": recipient_id,
"timestamp": edit_data.get("edited_at"),
"iv_b64": edit_data.get("previous_iv_b64"),
"ciphertext_b64": edit_data.get("previous_ciphertext_b64"),
"compliance_wrapped_mek_b64": edit_data.get("previous_compliance_wrapped_mek_b64"),
}
try:
previous_plaintext = decrypt_message(previous_message_data, compliance_private_key, compliance_public_key)
previous_parsed = parse_message_plaintext(previous_plaintext)
previous_text = previous_parsed.get("text") or previous_plaintext
# Save decrypted edit to output
edit_out_dir = msg_out_dir / "edits"
edit_out_dir.mkdir(parents=True, exist_ok=True)
edit_id = edit_data.get("edit_id")
(edit_out_dir / f"edit_{edit_id}.decrypted.txt").write_text(previous_plaintext, encoding="utf-8")
(edit_out_dir / f"edit_{edit_id}.decrypted.json").write_text(
json.dumps(
{
"edit_id": edit_id,
"message_id": message_id,
"edited_at": edit_data.get("edited_at"),
"edited_by_user_id": edit_data.get("edited_by_user_id"),
"edited_by_username": edit_data.get("edited_by_username"),
"plaintext": previous_plaintext,
"parsed": previous_parsed,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
edit_history.append(DecryptedEdit(
edit_id=int(edit_id),
edited_at=str(edit_data.get("edited_at") or ""),
edited_by_user_id=int(edit_data.get("edited_by_user_id") or 0),
edited_by_username=str(edit_data.get("edited_by_username") or "unknown"),
previous_text=str(previous_text),
))
except Exception as e:
print(f"Failed to decrypt edit {edit_entry.get('edit_id')}: {e}")
attachments: list[Attachment] = []
entry_files = entry.get("files")
if not isinstance(entry_files, list):
entry_files = []
for fentry in entry_files:
if not isinstance(fentry, dict):
continue
meta_rel = fentry.get("meta_file")
enc_rel = fentry.get("encrypted_file")
if not isinstance(meta_rel, str) or not isinstance(enc_rel, str):
continue
meta_abs = bundle_path / meta_rel
enc_abs = bundle_path / enc_rel
if not meta_abs.exists() or not enc_abs.exists():
continue
meta = json.loads(meta_abs.read_text(encoding="utf-8"))
if not isinstance(meta, dict):
continue
encrypted_bytes = enc_abs.read_bytes()
decrypted_bytes = decrypt_file_bytes_from_meta(meta, encrypted_bytes, key_file=key_file)
orig_name = str(meta.get("filename") or "file")
safe_name = safe_filename(orig_name)
out_file_abs = msg_files_out_dir / safe_name
if out_file_abs.exists():
root, ext = os.path.splitext(safe_name)
out_file_abs = msg_files_out_dir / f"{root}_{meta.get('dm_file_id') or 'x'}{ext}"
out_file_abs.write_bytes(decrypted_bytes)
out_rel = os.path.relpath(out_file_abs, out_path)
attachments.append(
Attachment(
filename=orig_name,
output_rel=out_rel,
size_bytes=len(decrypted_bytes),
is_image=guess_is_image(orig_name),
)
)
msg = DecryptedMessage(
message_id=int(message_id),
sender_id=sender_id,
sender_label=sender_label,
recipient_id=recipient_id,
recipient_label=recipient_label,
timestamp=ts,
text=str(text),
attachments=attachments,
edit_history=edit_history,
)
conv_key = _conversation_key(sender_id, recipient_id)
conversations.setdefault(conv_key, []).append(msg)
if conv_key not in conversation_names:
left_id, right_id = conv_key
if sender_id == left_id:
left_name = _best_username(sender_username, sender_display_name, left_id)
right_name = _best_username(recipient_username, recipient_display_name, right_id)
else:
left_name = _best_username(recipient_username, recipient_display_name, left_id)
right_name = _best_username(sender_username, sender_display_name, right_id)
conversation_names[conv_key] = (left_name, right_name)
css_rel, js_rel = write_assets(out_path)
_render_report(out_path, conversations, conversation_names, css_rel, js_rel)
return str(out_path / "index.html")
@@ -0,0 +1,204 @@
from __future__ import annotations
import json
import os
from datetime import datetime
from typing import Any, Dict, List
from http_client import http_get_bytes, http_get_json, join_api_url
from utils import safe_filename
def _fetch_user_profile(api_base_url: str, token: str, user_id: int) -> Dict[str, Any]:
url = f"{api_base_url.rstrip('/')}/user/id/{user_id}"
data = http_get_json(url, token)
return data if isinstance(data, dict) else {}
def extract_single_message_to_bundle(api_base_url: str, token: str, message_id: int, bundle_root: str) -> Dict[str, Any]:
message_dir = os.path.join(bundle_root, "messages", str(message_id))
files_dir = os.path.join(message_dir, "files")
os.makedirs(files_dir, exist_ok=True)
extract_url = f"{api_base_url.rstrip('/')}/dm/compliance/extract/{message_id}"
payload = http_get_json(extract_url, token)
raw_path = os.path.join(message_dir, "response.json")
with open(raw_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
raise RuntimeError(f"Unexpected response format for message_id={message_id}: missing 'data' object")
msg_path = os.path.join(message_dir, "message.json")
with open(msg_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
sender_id = data.get("sender_id")
recipient_id = data.get("recipient_id")
if not isinstance(sender_id, int) or not isinstance(recipient_id, int):
raise RuntimeError(f"Extraction JSON missing sender_id/recipient_id for message_id={message_id}")
sender_profile = _fetch_user_profile(api_base_url, token, sender_id)
recipient_profile = _fetch_user_profile(api_base_url, token, recipient_id)
sender_username = sender_profile.get("username") if isinstance(sender_profile.get("username"), str) else None
sender_display_name = sender_profile.get("display_name") if isinstance(sender_profile.get("display_name"), str) else None
recipient_username = recipient_profile.get("username") if isinstance(recipient_profile.get("username"), str) else None
recipient_display_name = (
recipient_profile.get("display_name") if isinstance(recipient_profile.get("display_name"), str) else None
)
sender_pk_url = f"{api_base_url.rstrip('/')}/crypto/public-key/of/{sender_id}"
sender_pk_resp = http_get_json(sender_pk_url, token)
sender_public_key_b64 = sender_pk_resp.get("publicKey")
if not isinstance(sender_public_key_b64, str) or not sender_public_key_b64:
raise RuntimeError(f"Could not fetch sender public key for user_id={sender_id}")
files = data.get("files") or []
if not isinstance(files, list):
files = []
file_entries: list[Dict[str, Any]] = []
for fmeta in files:
if not isinstance(fmeta, dict):
continue
file_id = fmeta.get("id")
name = fmeta.get("name") or "file"
path = fmeta.get("path")
wrapped_mek_b64 = fmeta.get("wrapped_mek_b64")
nonce_b64 = fmeta.get("nonce_b64")
if not path or not isinstance(path, str):
continue
safe_name = safe_filename(str(name))
enc_filename = f"{message_id}_{file_id or 'x'}_{safe_name}.enc"
enc_abs = os.path.join(files_dir, enc_filename)
enc_rel = os.path.relpath(enc_abs, bundle_root)
file_url = join_api_url(api_base_url, path)
file_bytes = http_get_bytes(file_url, token, timeout_seconds=60.0)
with open(enc_abs, "wb") as outf:
outf.write(file_bytes)
meta_out = {
"kind": "dm_file",
"message_id": data.get("message_id"),
"dm_file_id": file_id,
"filename": name,
"path": path,
"nonce_b64": nonce_b64,
"wrapped_mek_b64": wrapped_mek_b64,
"wrap_context": "sender_wrap_key",
"wrap_public_key_b64": sender_public_key_b64,
"encrypted_file_local": enc_rel,
}
meta_filename = f"{message_id}_{file_id or 'x'}_{safe_name}.meta.json"
meta_abs = os.path.join(files_dir, meta_filename)
meta_rel = os.path.relpath(meta_abs, bundle_root)
with open(meta_abs, "w", encoding="utf-8") as mf:
json.dump(meta_out, mf, ensure_ascii=False, indent=2)
file_entries.append(
{
"dm_file_id": file_id,
"filename": name,
"encrypted_file": enc_rel,
"meta_file": meta_rel,
"size_bytes": len(file_bytes),
}
)
# Handle edit history
edit_history = data.get("edit_history") or []
if not isinstance(edit_history, list):
edit_history = []
edit_history_entries: list[Dict[str, Any]] = []
for edit_entry in edit_history:
if not isinstance(edit_entry, dict):
continue
edit_id = edit_entry.get("edit_id")
edit_timestamp = edit_entry.get("edited_at")
edited_by_user_id = edit_entry.get("edited_by_user_id")
edited_by_username = edit_entry.get("edited_by_username")
if not isinstance(edit_id, int) or not isinstance(edit_timestamp, str):
continue
# Create separate JSON file for each edit history entry
edit_data = {
"edit_id": edit_id,
"message_id": message_id,
"edited_at": edit_timestamp,
"edited_by_user_id": edited_by_user_id,
"edited_by_username": edited_by_username,
"previous_ciphertext_b64": edit_entry.get("previous_ciphertext_b64"),
"previous_iv_b64": edit_entry.get("previous_iv_b64"),
"previous_compliance_wrapped_mek_b64": edit_entry.get("previous_compliance_wrapped_mek_b64"),
}
edit_filename = f"edit_{edit_id}.json"
edit_path = os.path.join(message_dir, "edits", edit_filename)
os.makedirs(os.path.dirname(edit_path), exist_ok=True)
edit_rel = os.path.relpath(edit_path, bundle_root)
with open(edit_path, "w", encoding="utf-8") as f:
json.dump(edit_data, f, ensure_ascii=False, indent=2)
edit_history_entries.append({
"edit_id": edit_id,
"edit_data_file": edit_rel,
"edited_at": edit_timestamp,
"edited_by_user_id": edited_by_user_id,
"edited_by_username": edited_by_username,
})
return {
"message_id": message_id,
"message_data_file": os.path.relpath(msg_path, bundle_root),
"response_file": os.path.relpath(raw_path, bundle_root),
"sender_id": sender_id,
"sender_username": sender_username,
"sender_display_name": sender_display_name,
"recipient_id": recipient_id,
"recipient_username": recipient_username,
"recipient_display_name": recipient_display_name,
"timestamp": data.get("timestamp"),
"files": file_entries,
"edit_history": edit_history_entries,
}
def extract_bundle(api_base_url: str, token: str, message_ids: List[int], out_dir: str) -> str:
os.makedirs(os.path.join(out_dir, "messages"), exist_ok=True)
seen: set[int] = set()
unique_ids: list[int] = []
for mid in message_ids:
if mid not in seen:
seen.add(mid)
unique_ids.append(mid)
if not unique_ids:
raise RuntimeError("No message IDs provided")
manifest: Dict[str, Any] = {
"bundle_version": 1,
"generated_at": datetime.now().isoformat(),
"api_base_url": api_base_url.rstrip("/"),
"messages": [],
}
for mid in unique_ids:
entry = extract_single_message_to_bundle(api_base_url, token, mid, out_dir)
manifest["messages"].append(entry)
manifest_path = os.path.join(out_dir, "bundle.json")
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
return manifest_path
+470
View File
@@ -0,0 +1,470 @@
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from getpass import getpass
from typing import Any, Dict, List, Optional, Sequence, Tuple
from bundle_decrypt import decrypt_bundle
from bundle_extract import extract_bundle
from crypto import derive_auth_secret
from http_client import http_get_json, http_post_json
class _Ansi:
RESET = "\033[0m"
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
MAGENTA = "\033[35m"
INDENT = 0
def indent() -> None:
global INDENT
INDENT += 2
def unindent() -> None:
global INDENT
INDENT = max(0, INDENT - 2)
def _pad() -> str:
return " " * INDENT
def _color(text: str, color: str) -> str:
return f"{color}{text}{_Ansi.RESET}"
def success(msg: str) -> None:
print(f"{_pad()}{_Ansi.GREEN}{_Ansi.RESET} {msg}")
def warning(msg: str) -> None:
print(f"{_pad()}{_Ansi.YELLOW}{_Ansi.RESET} {msg}")
def error(msg: str) -> None:
print(f"{_pad()}{_Ansi.RED}{_Ansi.RESET} {msg}")
def step(msg: str) -> None:
print(f"{_pad()}{_Ansi.CYAN}{_Ansi.BOLD}{_Ansi.RESET} {_Ansi.BOLD}{msg}{_Ansi.RESET}")
indent()
def substep(msg: str) -> None:
print(f"{_pad()}{_Ansi.GREEN}{_Ansi.RESET} {msg}")
def _prompt(text: str, *, default: Optional[str] = None, secret: bool = False, icon: str = "bullet") -> str:
suffix = f" [{default}]" if default is not None and default != "" else ""
if icon == "warning":
icon_str = f"{_Ansi.YELLOW}{_Ansi.RESET}"
else: # default "bullet"
icon_str = f"{_Ansi.GREEN}{_Ansi.RESET}"
q = f"{_pad()}{icon_str} {text}{suffix}: "
while True:
v = (getpass(q) if secret else input(q)).strip()
if v:
return v
if default is not None:
return default
warning("Value is required.")
def _prompt_choice(*, default: str) -> str:
"""
Choice prompt in the style:
\\n{indent}{dot} Your choice: (default X)
"""
q = f"\n{_pad()}{_Ansi.GREEN}{_Ansi.RESET} Your choice: (default {default}): "
v = input(q).strip()
return v or default
def _choose_option(options: Sequence[str], *, default: str) -> str:
substep("Choose an option:")
indent()
try:
for opt in options:
substep(opt)
return _prompt_choice(default=default)
finally:
unindent()
def _prompt_bool(text: str, *, default: bool = True) -> bool:
suffix = " [Y/n]" if default else " [y/N]"
q = f"{_pad()}{_Ansi.GREEN}{_Ansi.RESET} {text}{suffix}: "
while True:
v = input(q).strip().lower()
if not v:
return default
if v in {"y", "yes"}:
return True
if v in {"n", "no"}:
return False
warning("Please answer y/n.")
def _prompt_bool_required(text: str) -> bool:
"""
Ask a y/n question with no default (user must enter y or n).
"""
suffix = " [y/n]"
q = f"{_pad()}{_Ansi.GREEN}{_Ansi.RESET} {text}{suffix}: "
while True:
v = input(q).strip().lower()
if v in {"y", "yes"}:
return True
if v in {"n", "no"}:
return False
warning("Please answer y/n.")
def _parse_message_ids(raw: str) -> List[int]:
tokens = [t.strip() for t in raw.replace(",", " ").split() if t.strip()]
out: list[int] = []
for t in tokens:
if "-" in t:
a, b = t.split("-", 1)
start = int(a.strip())
end = int(b.strip())
if start <= end:
out.extend(list(range(start, end + 1)))
else:
out.extend(list(range(start, end - 1, -1)))
else:
out.append(int(t))
seen: set[int] = set()
uniq: list[int] = []
for x in out:
if x not in seen:
seen.add(x)
uniq.append(x)
return uniq
def _build_api_base(server: str, *, https: bool) -> str:
s = (server or "").strip()
if s.startswith("http://"):
s = s[len("http://") :]
if s.startswith("https://"):
s = s[len("https://") :]
scheme = "https" if https else "http"
return f"{scheme}://{s}/api"
@dataclass(frozen=True)
class _AuthResult:
api_base_url: str
token: str
did_login: bool
def _login(api_base_url: str, username: str, password: str) -> str:
derived = derive_auth_secret(username, password)
resp = http_post_json(f"{api_base_url.rstrip('/')}/login", {"username": username, "password": derived})
token = resp.get("token") if isinstance(resp, dict) else None
if not isinstance(token, str) or not token:
raise RuntimeError("Login did not return a token")
return token
def _logout(api_base_url: str, token: str) -> None:
try:
http_get_json(f"{api_base_url.rstrip('/')}/logout", token)
except Exception:
# Must best-effort logout; don't mask original errors.
pass
def _ensure_online_auth(
*,
server: Optional[str],
https: Optional[bool],
jwt: Optional[str],
username: Optional[str],
password: Optional[str],
) -> _AuthResult:
if not server:
server = _prompt("Server (host:port)", default="localhost:8301")
use_https = bool(https) if https is not None else _prompt_bool("Use HTTPS", default=True)
api_base_url = _build_api_base(server, https=use_https)
if jwt and (username or password):
raise SystemExit("Provide either --jwt OR --username/--password, not both.")
if jwt:
return _AuthResult(api_base_url=api_base_url, token=jwt.strip(), did_login=False)
step("Authentication")
try:
if not username and password is None:
method = _choose_option(["1) Login + password", "2) JWT token"], default="1")
if method.strip() == "2":
jwt_in = _prompt("JWT token")
return _AuthResult(api_base_url=api_base_url, token=jwt_in.strip(), did_login=False)
if not username:
username = _prompt("Username")
if password is None:
password = _prompt("Password", secret=True)
token = _login(api_base_url, username, password)
return _AuthResult(api_base_url=api_base_url, token=token, did_login=True)
finally:
unindent()
def cmd_extract(args: argparse.Namespace) -> None:
if getattr(args, "https", False) and getattr(args, "http", False):
raise SystemExit("Choose only one: --https or --http")
server = args.server
if not server:
server = _prompt("Server (host:port)", default="fromchat.ru")
if args.https or args.http:
https_choice: Optional[bool] = True if args.https else False
else:
https_choice = _prompt_bool_required("Use HTTPS")
jwt: Optional[str] = args.jwt
username: Optional[str] = args.username
password: Optional[str] = args.password
message_ids: List[int] = []
if getattr(args, "message_ids", None):
message_ids.extend(list(args.message_ids))
if not message_ids:
message_ids = []
out_dir = args.out_dir
last_err: Optional[BaseException] = None
for attempt in range(1, 6):
try:
auth = _ensure_online_auth(
server=server,
https=https_choice,
jwt=jwt,
username=username,
password=password,
)
except Exception as e:
last_err = e
msg = str(e)
warning(msg)
if "HTTP 401" in msg or "HTTP 403" in msg:
warning("Auth failed. Please enter username and password again.")
jwt = None
username = _prompt("Username")
password = _prompt("Password", secret=True)
continue
jwt = None
username = None
password = None
if not _prompt_bool("Try again", default=True):
raise SystemExit(1)
continue
if not message_ids:
raw = _prompt("Message IDs (space/comma, ranges like 1-5 supported)")
message_ids = _parse_message_ids(raw)
if not out_dir:
out_dir = _prompt("Output directory", default="./tmp/compliance_bundle")
step(f"Extracting {len(message_ids)} message(s)")
try:
manifest_path = extract_bundle(auth.api_base_url, auth.token, message_ids, out_dir)
success(f"Bundle created: {out_dir}")
success(f"Manifest: {manifest_path}")
return
except Exception as e:
last_err = e
msg = str(e)
if "HTTP 401" in msg or "HTTP 403" in msg:
warning(msg)
warning("Auth failed. Please enter username and password again.")
jwt = None
username = _prompt("Username")
password = _prompt("Password", secret=True)
continue
else:
raise
finally:
unindent()
if auth.did_login:
_logout(auth.api_base_url, auth.token)
if last_err:
raise SystemExit(str(last_err))
raise SystemExit(1)
def cmd_decrypt_bundle(args: argparse.Namespace) -> None:
bundle_dir = args.bundle_dir or _prompt("Bundle directory (contains bundle.json)", default="./tmp/compliance_bundle")
output_dir = args.output_dir or _prompt("Output directory", default="./tmp/compliance_bundle_decrypted")
# Try to load the compliance key, prompt for path if not found
key_file = "compliance_keypair.txt"
private_key_b64 = None
try:
from crypto import load_compliance_private_key
load_compliance_private_key(key_file=key_file)
except FileNotFoundError:
warning(f"Compliance key file not found: {key_file}")
key_file = _prompt("Path to compliance_keypair.txt")
except Exception as e:
# If file exists but key can't be loaded, ask user to paste it
private_key_b64 = _prompt("Couldn't find the private key. Please enter the X25519 PRIVATE key (base64, 43 chars)", secret=False, icon="warning")
if not private_key_b64 or not private_key_b64.strip():
raise RuntimeError("No private key provided")
# Create a temporary key file
import tempfile
import os
temp_fd, temp_path = tempfile.mkstemp(suffix='.txt', prefix='compliance_key_')
try:
with os.fdopen(temp_fd, 'w') as f:
f.write(f"PRIVATE_KEY={private_key_b64.strip()}\n")
f.write("PUBLIC_KEY=dummy\n") # Not needed for decryption
key_file = temp_path
except Exception:
os.close(temp_fd)
raise
step("Decrypting bundle")
try:
index_path = decrypt_bundle(bundle_dir, output_dir, key_file=key_file)
success(f"Bundle decrypted into: {output_dir}")
success(f"Report: {index_path}")
except Exception as e:
# Provide user-friendly error messages for common issues
if "InvalidTag" in str(type(e)) or "InvalidTag" in str(e):
error("Failed to decrypt bundle: Key mismatch - the bundle was encrypted with a different compliance key")
else:
error(f"Failed to decrypt bundle: {repr(e) if e else type(e).__name__}")
# Don't re-raise since we've already displayed the error
finally:
unindent()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Compliance Message Decryption Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
extract_parser = subparsers.add_parser("extract", help="Extract messages + encrypted files from API (online)")
extract_parser.add_argument("--server", required=False, help="Server host:port (e.g. localhost:8301)")
extract_parser.add_argument("--https", action="store_true", help="Use HTTPS (default in interactive mode)")
extract_parser.add_argument("--http", action="store_true", help="Use HTTP")
extract_parser.add_argument("--jwt", required=False, help="JWT token (Bearer)")
extract_parser.add_argument("--username", required=False, help="Login username (alternative to --jwt)")
extract_parser.add_argument("--password", required=False, help="Login password (will be prompted if omitted)")
extract_parser.add_argument("--message-ids", required=False, type=int, nargs="+", help="Message IDs to extract")
extract_parser.add_argument("--out-dir", required=False, help="Directory to write the extracted bundle")
extract_parser.set_defaults(func=cmd_extract)
decrypt_bundle_parser = subparsers.add_parser("decrypt", help="Decrypt a bundle created by extract (offline)")
decrypt_bundle_parser.add_argument("--bundle-dir", required=False, help="Path to extracted bundle directory (contains bundle.json)")
decrypt_bundle_parser.add_argument("--output-dir", required=False, help="Directory to write decrypted output (HTML + files)")
decrypt_bundle_parser.set_defaults(func=cmd_decrypt_bundle)
return parser
def _run_full_interactive() -> None:
print(f"{_Ansi.MAGENTA}{_Ansi.BOLD}FromChat compliance tool{_Ansi.RESET}\n")
step("Choose an action")
try:
choice = _choose_option(
[
"1) Extract bundle from server",
"2) Decrypt bundle (offline)",
"0) Exit",
],
default="1",
)
finally:
unindent()
if choice == "0":
raise SystemExit(0)
try:
if choice == "1":
step("Extract bundle from server")
try:
args = argparse.Namespace(
server=None,
https=False,
http=False,
jwt=None,
username=None,
password=None,
message_ids=None,
out_dir=None,
)
cmd_extract(args)
finally:
unindent()
elif choice == "2":
step("Decrypt bundle (offline)")
try:
args = argparse.Namespace(bundle_dir=None, output_dir=None)
cmd_decrypt_bundle(args)
finally:
unindent()
else:
warning("Unknown choice.")
except SystemExit:
raise
except Exception as e:
error(str(e))
def main(argv: List[str] | None = None) -> None:
try:
parser = build_parser()
if argv is None and len(sys.argv) <= 1:
_run_full_interactive()
return
args = parser.parse_args(argv)
if not getattr(args, "command", None):
_run_full_interactive()
return
try:
args.func(args)
except SystemExit:
raise
except Exception as e:
error(str(e))
raise SystemExit(1)
except KeyboardInterrupt:
pass
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import base64
import os
from typing import Any, Dict, Iterable, Optional
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def load_compliance_private_key(key_file: str = "compliance_keypair.txt") -> X25519PrivateKey:
if not os.path.exists(key_file):
raise FileNotFoundError(f"Compliance key file not found: {key_file}")
with open(key_file, "r", encoding="utf-8") as f:
content = f.read()
private_key_b64: Optional[str] = None
for line in content.split("\n"):
line = line.strip()
# Look for PRIVATE_KEY= line or base64 lines that are exactly 43 chars (X25519 private key length when base64 encoded)
if line.startswith("PRIVATE_KEY="):
private_key_b64 = line.split("=", 1)[1].strip()
break
elif len(line) == 43 and line.endswith("=") and "=" in line: # Base64 X25519 private key
private_key_b64 = line
break
if not private_key_b64:
raise ValueError(f"Could not find private key in {key_file}. Expected PRIVATE_KEY= line or 43-character base64 string.")
private_key_bytes = base64.b64decode(private_key_b64)
return X25519PrivateKey.from_private_bytes(private_key_bytes)
def _hkdf_32(info: bytes) -> HKDF:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b"\x00" * 16,
info=info,
)
def derive_wrap_key_from_public_key_bytes(public_key_bytes: bytes, context: str) -> bytes:
return _hkdf_32(context.encode("utf-8")).derive(public_key_bytes)
def derive_compliance_wrap_key(compliance_public_key: X25519PublicKey) -> bytes:
return _hkdf_32(b"compliance_wrap_key").derive(compliance_public_key.public_bytes_raw())
def decrypt_compliance_mek(
wrapped_mek_b64: str,
compliance_private_key: X25519PrivateKey,
compliance_public_key: X25519PublicKey,
) -> bytes:
wrap_key = derive_compliance_wrap_key(compliance_public_key)
wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64)
nonce = wrapped_mek_bytes[:12]
ciphertext = wrapped_mek_bytes[12:]
aesgcm = AESGCM(wrap_key)
return aesgcm.decrypt(nonce, ciphertext, None)
def decrypt_wrapped_mek_with_public_key(wrapped_mek_b64: str, wrap_public_key_b64: str, wrap_context: str) -> bytes:
public_key_bytes = base64.b64decode(wrap_public_key_b64)
wrap_key = derive_wrap_key_from_public_key_bytes(public_key_bytes, wrap_context)
wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64)
nonce = wrapped_mek_bytes[:12]
ciphertext = wrapped_mek_bytes[12:]
aesgcm = AESGCM(wrap_key)
return aesgcm.decrypt(nonce, ciphertext, None)
def first_present_key(data: Dict[str, Any], keys: Iterable[str]) -> Optional[str]:
for k in keys:
v = data.get(k)
if v is None:
continue
if isinstance(v, str) and v.strip() == "":
continue
return k
return None
def get_str(data: Dict[str, Any], keys: Iterable[str], label: str) -> str:
k = first_present_key(data, keys)
if not k:
raise ValueError(f"Missing {label}. Expected one of: {', '.join(keys)}")
v = data.get(k)
if not isinstance(v, str):
raise ValueError(f"Invalid {label}: expected string at '{k}', got {type(v).__name__}")
return v
def decrypt_message(envelope_data: Dict[str, Any], compliance_private_key: X25519PrivateKey, compliance_public_key: X25519PublicKey) -> str:
compliance_wrapped_mek = envelope_data.get("compliance_wrapped_mek_b64")
if not compliance_wrapped_mek:
raise ValueError("Message does not have compliance MEK")
mek = decrypt_compliance_mek(compliance_wrapped_mek, compliance_private_key, compliance_public_key)
nonce_b64 = envelope_data["iv_b64"]
ciphertext_b64 = envelope_data["ciphertext_b64"]
nonce = base64.b64decode(nonce_b64)
ciphertext = base64.b64decode(ciphertext_b64)
aesgcm = AESGCM(mek)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")
def decrypt_file_bytes_from_meta(meta: Dict[str, Any], encrypted_bytes: bytes, *, key_file: str = "compliance_keypair.txt") -> bytes:
nonce_b64 = get_str(meta, keys=["nonce_b64", "iv_b64", "nonce", "iv"], label="nonce/iv (base64)")
nonce = base64.b64decode(nonce_b64)
mek_key = first_present_key(meta, ["compliance_wrapped_mek_b64", "compliance_wrapped_mek"])
if mek_key:
compliance_private_key = load_compliance_private_key(key_file=key_file)
compliance_public_key = compliance_private_key.public_key()
mek = decrypt_compliance_mek(str(meta[mek_key]), compliance_private_key, compliance_public_key)
else:
wrap_public_key_b64 = get_str(
meta,
keys=["wrap_public_key_b64", "wrap_public_key", "public_key_b64"],
label="wrap public key (base64)",
)
wrap_context = get_str(meta, keys=["wrap_context"], label="wrap context")
wrapped_mek_b64 = get_str(meta, keys=["wrapped_mek_b64", "wrapped_mek"], label="wrapped MEK (base64)")
mek = decrypt_wrapped_mek_with_public_key(wrapped_mek_b64, wrap_public_key_b64, wrap_context)
aesgcm = AESGCM(mek)
return aesgcm.decrypt(nonce, encrypted_bytes, None)
def derive_auth_secret(username: str, password: str) -> str:
"""
Match frontend `deriveAuthSecret()`:
HKDF-SHA256 with:
- IKM: UTF-8 password
- salt: UTF-8 `fromchat.user:{username}`
- info: UTF-8 `auth-secret`
- length: 32 bytes
Output: base64 string.
"""
salt = f"fromchat.user:{(username or '').strip()}".encode("utf-8")
info = b"auth-secret"
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=info,
)
derived = hkdf.derive((password or "").encode("utf-8"))
return base64.b64encode(derived).decode("ascii")
+1
View File
@@ -6,6 +6,7 @@ echo > deployment/.env
cat >> deployment/.env <<EOF
JWT_SECRET="$(openssl rand -base64 32)"
COMPLIANCE_PUBLIC_KEY="$(./.venv/bin/python3 scripts/generate_compliance_keypair.py --save --public-only)"
TURN_USERNAME=<set>
TURN_SECRET=<set>
DEPLOYMENT_SERVER=<set>
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Generate compliance system X25519 keypair for offline air-gapped storage.
This script generates an X25519 keypair for the compliance system.
The private key should be stored offline on an air-gapped machine.
Only the public key is provided to the messaging service via COMPLIANCE_PUBLIC_KEY env var.
Usage:
python3 scripts/generate_compliance_keypair.py
Output:
- Prints the keypair to console
- Optionally saves to a file
"""
import base64
import sys
import os
from pathlib import Path
import argparse
try:
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
except ImportError:
print("Error: cryptography library required")
print("Install with: pip install cryptography")
sys.exit(1)
def generate_compliance_keypair():
"""
Generate X25519 keypair for compliance system.
Returns:
Tuple of (private_key_b64, public_key_b64)
"""
# Generate X25519 keypair
private_key = X25519PrivateKey.generate()
public_key = private_key.public_key()
# Export keys
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption()
)
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
# Convert to base64
private_b64 = base64.b64encode(private_bytes).decode('utf-8')
public_b64 = base64.b64encode(public_bytes).decode('utf-8')
return private_b64, public_b64
def main():
"""Generate and display compliance keypair."""
parser = argparse.ArgumentParser(
description="Generate compliance system X25519 keypair"
)
parser.add_argument(
"--save",
action="store_true",
help="Save keypair to compliance_keypair.txt file"
)
parser.add_argument(
"--public-only",
action="store_true",
help="Output only the public key (for scripts)"
)
args = parser.parse_args()
private_b64, public_b64 = generate_compliance_keypair()
if args.public_only:
# Output only public key for script integration
print(public_b64)
else:
# Full interactive display
output = f"""
╔════════════════════════════════════════════════════════════════╗
║ COMPLIANCE SYSTEM X25519 KEYPAIR ║
║ (Generated for testing/development only) ║
╚════════════════════════════════════════════════════════════════╝
PRIVATE KEY (STORE OFFLINE ON AIR-GAPPED MACHINE):
{private_b64}
PUBLIC KEY (SET AS COMPLIANCE_PUBLIC_KEY ENV VAR):
{public_b64}
CONFIGURATION:
For local development:
export COMPLIANCE_PUBLIC_KEY="{public_b64}"
For Docker/docker-compose:
Add to deployment/.env:
COMPLIANCE_PUBLIC_KEY={public_b64}
For production:
Generate on air-gapped machine, export public key only
Store private key offline in secure location
⚠️ SECURITY WARNING:
- Keep the PRIVATE KEY offline on an air-gapped machine
- Only the PUBLIC KEY should be deployed to servers
- Never commit private key to version control
- For production, use cryptographically secure key generation
"""
print(output)
# Handle file saving
if args.save:
script_dir = Path(__file__).parent
project_root = script_dir.parent
output_file = project_root / "compliance_keypair.txt"
full_output = f"""COMPLIANCE SYSTEM X25519 KEYPAIR
Generated: {__import__('datetime').datetime.now().isoformat()}
================================================================================
PRIVATE KEY (STORE OFFLINE ON AIR-GAPPED MACHINE):
{private_b64}
PUBLIC KEY (SET AS COMPLIANCE_PUBLIC_KEY ENV VAR):
{public_b64}
================================================================================
⚠️ SECURITY WARNING:
- Keep the PRIVATE KEY offline on an air-gapped machine
- Only the PUBLIC KEY should be deployed to servers
- Never commit private key to version control
"""
with open(output_file, 'w') as f:
f.write(full_output)
print(f"✓ Keypair saved to: {output_file}", file=sys.stderr)
if __name__ == "__main__":
main()