Restructure compliance tools

This commit is contained in:
2026-03-27 15:22:07 +03:00
Unverified
parent f73c92c77c
commit 30ab4d9190
16 changed files with 14 additions and 11 deletions
@@ -0,0 +1,347 @@
/* FromChat compliance bundle report styles (conversation-like, minimal JS) */
:root {
--bg: #0b0f14;
--panel: #0f1520;
--panel-2: #121a27;
--text: #e6edf3;
--muted: #9aa7b2;
--border: #223045;
--accent: #4f7cff;
--bubble-in: #131b28;
--bubble-out: #1a2540;
--shadow: rgba(0, 0, 0, 0.35);
}
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 22px 14px 64px;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(10px);
background: rgba(11, 15, 20, 0.75);
border-bottom: 1px solid rgba(34, 48, 69, 0.7);
}
.topbar-inner {
max-width: 980px;
margin: 0 auto;
padding: 14px 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.2px;
}
.brand-subtitle {
font-size: 12px;
color: var(--muted);
white-space: normal;
word-break: break-word;
}
.tools {
display: flex;
gap: 10px;
align-items: center;
}
.search {
width: min(420px, 55vw);
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 9px 10px;
color: var(--text);
outline: none;
}
.search:focus {
border-color: rgba(79, 124, 255, 0.65);
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.18);
}
.hint {
font-size: 12px;
color: var(--muted);
}
.conversation {
margin-top: 16px;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 28px var(--shadow);
}
.conv-header {
cursor: default;
padding: 14px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid rgba(34, 48, 69, 0.65);
}
.conv-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.conv-title .line1 {
font-weight: 700;
font-size: 14px;
}
.conv-title .line2 {
font-size: 12px;
color: var(--muted);
}
.conv-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
color: var(--muted);
font-size: 12px;
}
.pill {
border: 1px solid rgba(34, 48, 69, 0.9);
border-radius: 999px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.02);
}
.messages {
padding: 12px 10px 14px;
}
.day {
display: flex;
justify-content: center;
margin: 12px 0 10px;
}
.day span {
font-size: 12px;
color: var(--muted);
border: 1px solid rgba(34, 48, 69, 0.8);
background: rgba(255, 255, 255, 0.02);
padding: 3px 10px;
border-radius: 999px;
}
.message-container {
display: flex;
margin: 8px 0;
gap: 12px;
align-items: flex-start;
}
.edit-tabs-vertical {
display: flex;
flex-direction: column;
min-width: 80px;
gap: 4px;
}
.tab-vertical {
padding: 6px 4px;
cursor: pointer;
border: 1px solid rgba(34, 48, 69, 0.6);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
text-align: center;
transition: background-color 0.15s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
min-height: 40px;
}
.tab-vertical:hover {
background: rgba(79, 124, 255, 0.08);
}
.tab-vertical.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.tab-label-vertical {
font-size: 10px;
font-weight: 700;
line-height: 1.1;
}
.tab-time-vertical {
font-size: 8px;
opacity: 0.9;
line-height: 1.1;
white-space: nowrap;
}
.bubble-area {
flex: 1;
min-width: 0;
}
.bubble {
display: none;
max-width: min(720px, 92%);
border: 1px solid rgba(34, 48, 69, 0.9);
border-radius: 16px;
padding: 10px 10px 9px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
background: var(--bubble-in);
border-top-left-radius: 6px;
}
.bubble.active {
display: block;
}
.bubble-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.who {
font-size: 12px;
color: var(--muted);
}
.who strong {
color: var(--text);
font-weight: 700;
}
.text {
white-space: pre-wrap;
word-break: break-word;
font-size: 14px;
line-height: 1.45;
}
.msg-meta {
margin-top: 8px;
font-size: 11px;
color: var(--muted);
display: flex;
justify-content: space-between;
gap: 10px;
}
.msg-meta-left {
white-space: nowrap;
}
.msg-meta-right {
white-space: nowrap;
text-align: right;
}
.attachments {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.att {
border: 1px solid rgba(34, 48, 69, 0.9);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
}
.att-name {
font-size: 13px;
font-weight: 700;
margin-bottom: 7px;
}
.thumb {
width: 100%;
max-height: 260px;
object-fit: contain;
border-radius: 10px;
border: 1px solid rgba(34, 48, 69, 0.9);
background: rgba(0, 0, 0, 0.18);
}
.att-actions {
margin-top: 8px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.att-size {
font-size: 12px;
color: var(--muted);
}
.footer {
margin-top: 18px;
font-size: 12px;
color: var(--muted);
padding: 10px 2px;
}
.hidden {
display: none !important;
}
@@ -0,0 +1,105 @@
/* Minimal JS for filtering messages in the static report. */
function normalizeText(s) {
return (s || "").toString().toLowerCase();
}
function filterReport(query) {
const q = normalizeText(query).trim();
const conversations = document.querySelectorAll(".conversation");
let anyVisible = false;
conversations.forEach((conv) => {
const rows = conv.querySelectorAll("[data-search]");
let visibleInConv = 0;
rows.forEach((row) => {
const hay = normalizeText(row.getAttribute("data-search"));
const match = !q || hay.includes(q);
row.classList.toggle("hidden", !match);
if (match) visibleInConv += 1;
});
const convMatch = visibleInConv > 0;
conv.classList.toggle("hidden", !convMatch);
if (convMatch) anyVisible = true;
});
const hint = document.getElementById("filterHint");
if (hint) {
hint.textContent = q
? (anyVisible ? "Filtered" : "No matches")
: "Type to filter by text, user id, filename";
}
}
function setupEditHistoryTabs() {
document.querySelectorAll(".edit-tabs-vertical").forEach((tabsContainer) => {
const tabs = tabsContainer.querySelectorAll(".tab-vertical");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const version = tab.getAttribute("data-version");
const messageId = tab.getAttribute("data-message-id");
// Find the corresponding message container
const messageContainer = document.querySelector(`.message-container:has([data-message-id="${messageId}"])`);
if (!messageContainer) return;
// Update tab states within this message
const allTabs = messageContainer.querySelectorAll(".tab-vertical");
allTabs.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// Update bubble states within this message
const allBubbles = messageContainer.querySelectorAll(".bubble");
allBubbles.forEach(bubble => {
bubble.classList.toggle("active", bubble.getAttribute("data-version") === version);
});
});
});
});
}
function convertTimestampsToLocal() {
// Convert all timestamps to local timezone
document.querySelectorAll("[data-timestamp]").forEach((element) => {
const timestamp = element.getAttribute("data-timestamp");
if (!timestamp) return;
try {
// Parse the ISO timestamp
const date = new Date(timestamp.replace(" ", "T").replace("Z", "+00:00"));
// Format in local timezone
const localTime = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
// Update the displayed text
element.textContent = localTime;
} catch (e) {
// If parsing fails, leave the original text
console.warn("Failed to parse timestamp:", timestamp);
}
});
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("searchInput");
if (input) {
input.addEventListener("input", (e) => {
filterReport(e.target.value);
});
}
// Initialize edit history tabs
setupEditHistoryTabs();
// Convert timestamps to local timezone
convertTimestampsToLocal();
});
@@ -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")
@@ -0,0 +1,74 @@
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from urllib import error, request
from urllib.parse import quote
def http_get_bytes(url: str, token: str, timeout_seconds: float = 30.0) -> bytes:
req = request.Request(url, method="GET")
req.add_header("Authorization", f"Bearer {token}")
try:
with request.urlopen(req, timeout=timeout_seconds) as r:
return r.read()
except error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
raise RuntimeError(f"HTTP {e.code} for {url}: {body[:500]}")
def http_get_json(url: str, token: str, timeout_seconds: float = 30.0) -> Dict[str, Any]:
raw = http_get_bytes(url, token, timeout_seconds=timeout_seconds)
try:
return json.loads(raw.decode("utf-8"))
except Exception as e:
raise RuntimeError(f"Failed to parse JSON from {url}: {e}")
def http_post_json(
url: str,
body: Dict[str, Any],
*,
token: Optional[str] = None,
timeout_seconds: float = 30.0,
) -> Dict[str, Any]:
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = request.Request(url, method="POST", data=payload)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with request.urlopen(req, timeout=timeout_seconds) as r:
raw = r.read()
except error.HTTPError as e:
body_txt = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
raise RuntimeError(f"HTTP {e.code} for {url}: {body_txt[:500]}")
try:
return json.loads(raw.decode("utf-8"))
except Exception as e:
raise RuntimeError(f"Failed to parse JSON from {url}: {e}")
def join_api_url(api_base_url: str, path: str) -> str:
"""
Join an API base URL (usually ends with '/api') with a path that may start with:
- '/api/...'
- '/uploads/...'
- 'uploads/...'
"""
base = api_base_url.rstrip("/")
p = (path or "").strip()
if p.startswith("http://") or p.startswith("https://"):
return p
p_quoted = quote(p, safe="/:?&=%")
if p.startswith("/api/"):
origin = base[:-4] if base.endswith("/api") else base
return origin.rstrip("/") + p_quoted
if not p.startswith("/"):
p_quoted = "/" + p_quoted
return base + p_quoted
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
FromChat compliance decryption tool entrypoint.
Run:
python scripts/compliance/decryption/main.py <command> ...
"""
from __future__ import annotations
import os
import sys
def main() -> None:
root_dir = os.path.dirname(os.path.abspath(__file__))
if root_dir not in sys.path:
sys.path.insert(0, root_dir)
from cli import main as cli_main
cli_main()
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
def assets_source_dir() -> Path:
"""
Directory that stores static templates (css/js) for report generation.
Layout:
scripts/compliance/decryption/
main.py
assets/
report.css
report.js
*.py
"""
root_dir = Path(__file__).resolve().parent
return root_dir / "assets"
def read_asset_text(name: str) -> str:
path = assets_source_dir() / name
return path.read_text(encoding="utf-8")
def write_assets(output_dir: Path) -> tuple[str, str]:
assets_dir = output_dir / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
css_src = read_asset_text("report.css")
js_src = read_asset_text("report.js")
css_rel = "assets/report.css"
js_rel = "assets/report.js"
(assets_dir / "report.css").write_text(css_src, encoding="utf-8")
(assets_dir / "report.js").write_text(js_src, encoding="utf-8")
return css_rel, js_rel
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import json
import os
from typing import Any, Dict
from urllib.parse import quote
def safe_filename(name: str, max_len: int = 140) -> str:
base = "".join(c for c in (name or "") if c.isalnum() or c in " ._-()[]{}").strip()
base = base.replace(" ", " ")
base = base.replace("/", "_").replace("\\", "_")
if not base:
base = "file"
if len(base) > max_len:
base = base[:max_len].rstrip()
return base
def html_escape(text: str) -> str:
return (
(text or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#039;")
)
def href_escape(rel_path: str) -> str:
"""
Percent-encode a relative path for use in HTML href/src.
Keep slashes so nested paths work.
"""
return quote(rel_path, safe="/")
def guess_is_image(filename: str) -> bool:
ext = (os.path.splitext(filename or "")[1] or "").lower()
return ext in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
def parse_message_plaintext(plaintext: str) -> Dict[str, Any]:
"""
Best-effort parse of decrypted message JSON.
Returns:
- kind: "json" | "text"
- text: best-effort human-readable text
- raw: original plaintext
- json: parsed object (if kind=="json")
"""
raw = plaintext or ""
try:
obj = json.loads(raw)
content = ""
if isinstance(obj, dict):
data = obj.get("data")
if isinstance(data, dict):
content_val = data.get("content")
if isinstance(content_val, str):
content = content_val
return {"kind": "json", "text": content or raw, "raw": raw, "json": obj}
except Exception:
return {"kind": "text", "text": raw, "raw": raw}