mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure backend into microservices, add envelope encryption, DM files, and message editing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user