Created
June 23, 2026 15:17
-
-
Save lastforkbender/34a9e663da2c393a971405550f19e3d6 to your computer and use it in GitHub Desktop.
FBK - SECNTG EDITOR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/ | |
| # \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\ | |
| # / FBK / SECNTG EDITOR / / PYQT5 APP / | |
| import os | |
| import re | |
| import sys | |
| import json | |
| import math | |
| import base64 | |
| import shutil | |
| import struct | |
| import tempfile | |
| import zipfile | |
| import ctypes | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from PyQt5.QtCore import Qt, QSize, QTimer | |
| from PyQt5.QtGui import (QFont, QFontMetrics, QPainter, QColor, QImage, QPixmap, QIcon) | |
| from PyQt5.QtWidgets import (QApplication, QMainWindow, QTextEdit, QAction, QFileDialog, QMessageBox, | |
| QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, | |
| QListWidget, QListWidgetItem, QCheckBox, QWidget, QFrame, QInputDialog, | |
| QToolBar, QStatusBar, QSizePolicy) | |
| try: | |
| from cryptography.hazmat.primitives.ciphers.aead import AESGCM | |
| from cryptography.hazmat.primitives.kdf.scrypt import Scrypt | |
| from cryptography.hazmat.backends import default_backend | |
| HAS_CRYPTO = True | |
| except Exception: | |
| AESGCM = None | |
| HAS_CRYPTO = False | |
| try: | |
| import fpdf | |
| HAS_FPDF = True | |
| except Exception: | |
| HAS_FPDF = False | |
| try: | |
| import py7zr | |
| HAS_7Z = True | |
| except Exception: | |
| HAS_7Z = False | |
| APP_NAME = "SECNTG EDITOR"; PAGE_W = 850; PAGE_H = 1100; PAGE_MARGIN = 60 | |
| CONTAINER_MAGIC = b"SECNTG\x01\x00"; CONTAINER_VERSION = 1 | |
| def secure_zero(b: bytearray) -> None: | |
| for i in range(len(b)): b[i] = 0 | |
| if len(b): ctypes.memset((ctypes.c_char * len(b)).from_buffer(b), 0, len(b)) | |
| def zero_bytes(data: bytes) -> None: | |
| try: | |
| buf = (ctypes.c_char * len(data)).from_buffer_copy(data); ctypes.memset(buf, 0, len(data)) | |
| except Exception: | |
| pass | |
| def gen_key() -> bytes: | |
| if not HAS_CRYPTO: | |
| return os.urandom(32) | |
| return AESGCM.generate_key(bit_length=256) | |
| def derive_key(password: str, salt: bytes) -> bytes: | |
| if not HAS_CRYPTO: | |
| raise RuntimeError("cryptography package required for key derivation") | |
| kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1, backend=default_backend()) | |
| return kdf.derive(password.encode("utf-8")) | |
| def aesgcm_encrypt(key: bytes, plaintext: bytes, aad: bytes = b""): | |
| if not HAS_CRYPTO: | |
| raise RuntimeError("cryptography package not installed") | |
| nonce = os.urandom(12); ct = AESGCM(key).encrypt(nonce, plaintext, aad) | |
| return nonce, ct | |
| def aesgcm_decrypt(key: bytes, nonce: bytes, ct: bytes, aad: bytes = b"") -> bytes: | |
| if not HAS_CRYPTO: | |
| raise RuntimeError("cryptography package not installed") | |
| return AESGCM(key).decrypt(nonce, ct, aad) | |
| def write_secure_container(path: str, plaintext_dict: dict, password: str, aad_label: str) -> None: | |
| salt = os.urandom(32); key = derive_key(password, salt); aad = aad_label.encode("utf-8") | |
| data = json.dumps(plaintext_dict, ensure_ascii=False).encode("utf-8") | |
| nonce, ct = aesgcm_encrypt(key, data, aad) | |
| aad_len = struct.pack("<I", len(aad)); ver_bytes = struct.pack("<I", CONTAINER_VERSION) | |
| with open(path, "wb") as f: f.write(CONTAINER_MAGIC + ver_bytes + salt + nonce + aad_len + aad + ct) | |
| kb = bytearray(key); secure_zero(kb); pb = bytearray(data); secure_zero(pb) | |
| def read_secure_container(path: str, password: str) -> dict: | |
| with open(path, "rb") as f: raw = f.read() | |
| if raw[:8] != CONTAINER_MAGIC: | |
| raise ValueError("Not a valid secure container") | |
| _ver = struct.unpack_from("<I", raw, 8)[0]; salt = raw[12:44] | |
| nonce = raw[44:56]; aad_len = struct.unpack_from("<I", raw, 56)[0] | |
| aad = raw[60: 60 + aad_len]; ct = raw[60 + aad_len:] | |
| key = derive_key(password, salt); pt = aesgcm_decrypt(key, nonce, ct, aad) | |
| result = json.loads(pt.decode("utf-8")) | |
| kb = bytearray(key); secure_zero(kb); pb = bytearray(pt); secure_zero(pb) | |
| return result | |
| def is_url_token(s: str) -> bool: | |
| return bool(re.fullmatch(r"https?://[^\s]+", s, flags=re.I)) | |
| def tokenize_text(text): | |
| return re.findall(r"\s+|[^\s]+", text) | |
| def wrap_tokens_to_lines(tokens, font_metrics, max_width): | |
| lines, current = [], "" | |
| for tok in tokens: | |
| candidate = current + tok | |
| if current and font_metrics.horizontalAdvance(candidate) > max_width and not tok.isspace(): | |
| lines.append(current.rstrip()); current = tok.lstrip() | |
| else: current = candidate | |
| if current.strip(): lines.append(current.rstrip()) | |
| return lines | |
| def load_stylesheet(): | |
| return """QMainWindow, QDialog { background: #1f1f24; color: #e8e8e8;} | |
| QTextEdit { background: #151518; color: #f0f0f0; border: 1px solid #4b2222; | |
| border-radius: 10px; padding: 10px; selection-background-color: #8c1f1f; | |
| selection-color: #ffffff;} | |
| QMenuBar { background: #26262b; color: #f2f2f2;} | |
| QMenuBar::item:selected { background: #6f1d1d; } QMenu { background: #222226; | |
| color: #f3f3f3; border: 1px solid #5c2222;} | |
| QMenu::item:selected { background: #8b1f1f; } QPushButton { | |
| background: qlineargradient(x1:0,y1:0,x2:1,y2:1, stop:0 #5b1c1c, stop:1 #9a2727); color: white; | |
| border: 1px solid #b54848; border-radius: 8px; padding: 7px 12px;} | |
| QPushButton:hover { background: qlineargradient(x1:0,y1:0,x2:1,y2:1, stop:0 #7a2222, stop:1 #c13030);} | |
| QLineEdit, QListWidget { background: #17171b; color: #f2f2f2; border: 1px solid #4a2222; | |
| border-radius: 8px; padding: 6px;} QLabel, QCheckBox { color: #ededed; } | |
| QToolBar { background: #1a1a1f; border-bottom: 1px solid #3a1a1a; } | |
| QStatusBar { background: #18181c; color: #888; }""" | |
| @dataclass | |
| class TagStore: | |
| items: list | |
| def __init__(self): | |
| self.items = [] | |
| def add(self, kind, value): | |
| tag = f"{{ntg{len(self.items)+1}}}" | |
| self.items.append({"tag": tag, "kind": kind, "value": value}) | |
| return tag | |
| def get(self, tag: str): | |
| for item in self.items: | |
| if item["tag"] == tag: | |
| return item | |
| return None | |
| def to_json(self): | |
| return json.dumps(self.items, indent=2, ensure_ascii=False) | |
| def clear(self): | |
| self.items.clear() | |
| def replace_tags_in_text(self, text: str, expand: bool = True) -> str: | |
| if not expand: | |
| return text | |
| for item in self.items: text = text.replace(item["tag"], item["value"]) | |
| return text | |
| def _make_lock_pixmap(locked: bool, size: int = 22) -> QPixmap: | |
| img = QImage(size, size, QImage.Format_ARGB32); img.fill(Qt.transparent) | |
| p = QPainter(img); p.setRenderHint(QPainter.Antialiasing) | |
| body_color = QColor("#c0392b") if locked else QColor("#27ae60") | |
| shackle_col = QColor("#e74c3c") if locked else QColor("#2ecc71") | |
| shackle_rect_x = size // 4; shackle_rect_w = size // 2 | |
| shackle_top = 2; shackle_bot = size // 2 - 1 | |
| p.setPen(p.pen().__class__(shackle_col, 2)); p.setBrush(Qt.NoBrush) | |
| if locked: | |
| from PyQt5.QtCore import QRect | |
| p.drawArc(shackle_rect_x, shackle_top, shackle_rect_w, shackle_bot - shackle_top + 2, 0, 180 * 16) | |
| else: | |
| from PyQt5.QtCore import QRect | |
| p.drawArc(shackle_rect_x + size // 5, shackle_top, shackle_rect_w, shackle_bot - shackle_top + 2, 0, 180 * 16) | |
| p.setPen(Qt.NoPen); p.setBrush(body_color) | |
| bx, by, bw, bh = 3, size // 2, size - 6, size // 2 - 2 | |
| p.drawRoundedRect(bx, by, bw, bh, 3, 3) | |
| p.setBrush(QColor("#1a1a1a")) | |
| kx, ky, kr = size // 2, by + bh // 2 - 1, 3 | |
| p.drawEllipse(kx - kr, ky - kr, kr * 2, kr * 2); p.drawRect(kx - 1, ky, 2, 4); p.end() | |
| return QPixmap.fromImage(img) | |
| class LockButton(QPushButton): | |
| def __init__(self, parent=None): | |
| super().__init__(parent) | |
| self._locked = True | |
| self.setFixedSize(36, 36) | |
| self.setToolTip("Toggle lock state") | |
| self.clicked.connect(self._toggle) | |
| self._refresh() | |
| def _refresh(self): | |
| pix = _make_lock_pixmap(self._locked) | |
| self.setIcon(QIcon(pix)) | |
| self.setIconSize(QSize(22, 22)) | |
| self.setText("") | |
| state = "Locked" if self._locked else "Unlocked" | |
| self.setToolTip(f"Document: {state} (click to toggle)") | |
| def _toggle(self): | |
| self._locked = not self._locked; self._refresh() | |
| @property | |
| def is_locked(self) -> bool: | |
| return self._locked | |
| def set_locked(self, val: bool): | |
| self._locked = val; self._refresh() | |
| class FindReplaceDialog(QDialog): | |
| def __init__(self, editor, parent=None): | |
| super().__init__(parent) | |
| self.editor = editor | |
| self.setWindowTitle("Find & Replace") | |
| self.setMinimumWidth(420) | |
| self.find_input = QLineEdit() | |
| self.replace_input = QLineEdit() | |
| self.case_box = QCheckBox("Case sensitive") | |
| btn_find = QPushButton("Find Next") | |
| btn_replace = QPushButton("Replace") | |
| btn_all = QPushButton("Replace All") | |
| btn_close = QPushButton("Close") | |
| btn_find.clicked.connect(self.find_next) | |
| btn_replace.clicked.connect(self.replace_one) | |
| btn_all.clicked.connect(self.replace_all) | |
| btn_close.clicked.connect(self.close) | |
| lay = QVBoxLayout(self) | |
| lay.addWidget(QLabel("Find")); lay.addWidget(self.find_input) | |
| lay.addWidget(QLabel("Replace")); lay.addWidget(self.replace_input) | |
| lay.addWidget(self.case_box) | |
| row = QHBoxLayout() | |
| for b in (btn_find, btn_replace, btn_all, btn_close): row.addWidget(b) | |
| lay.addLayout(row) | |
| def find_next(self): | |
| text = self.find_input.text() | |
| if not text: return | |
| flags = QTextEdit.FindFlags() | |
| if self.case_box.isChecked(): flags |= QTextEdit.FindCaseSensitively | |
| found = self.editor.find(text, flags) | |
| if not found: | |
| c = self.editor.textCursor(); c.movePosition(c.Start) | |
| self.editor.setTextCursor(c); self.editor.find(text, flags) | |
| def replace_one(self): | |
| cursor = self.editor.textCursor() | |
| if cursor.hasSelection(): cursor.insertText(self.replace_input.text()) | |
| self.find_next() | |
| def replace_all(self): | |
| find_text = self.find_input.text() | |
| if not find_text: return | |
| repl = self.replace_input.text() | |
| text = self.editor.toPlainText() | |
| if self.case_box.isChecked(): text = text.replace(find_text, repl) | |
| else: text = re.compile(re.escape(find_text), flags=re.I).sub(repl, text) | |
| self.editor.setPlainText(text) | |
| class NumberPickerDialog(QDialog): | |
| def __init__(self, numbers, parent=None): | |
| super().__init__(parent) | |
| self.setWindowTitle("Long Numbers") | |
| self.resize(460, 320) | |
| self.selected_value = None | |
| self.listw = QListWidget() | |
| for n in numbers: self.listw.addItem(QListWidgetItem(n)) | |
| btn_add = QPushButton("Add") | |
| btn_close = QPushButton("Close") | |
| btn_add.clicked.connect(self._add) | |
| btn_close.clicked.connect(self.close) | |
| lay = QVBoxLayout(self) | |
| lay.addWidget(QLabel("Select a number sequence to store:")) | |
| lay.addWidget(self.listw) | |
| row = QHBoxLayout() | |
| row.addWidget(btn_add); row.addWidget(btn_close) | |
| lay.addLayout(row) | |
| def _add(self): | |
| item = self.listw.currentItem() | |
| if item: | |
| self.selected_value = item.text(); self.accept() | |
| class InsertPickerDialog(QDialog): | |
| def __init__(self, parent=None): | |
| super().__init__(parent) | |
| self.setWindowTitle("Insert Text File") | |
| self.resize(520, 160) | |
| self.path_edit = QLineEdit() | |
| browse = QPushButton("Browse") | |
| ok = QPushButton("Insert") | |
| cancel = QPushButton("Cancel") | |
| browse.clicked.connect(self._browse) | |
| ok.clicked.connect(self.accept) | |
| cancel.clicked.connect(self.reject) | |
| row = QHBoxLayout(); row.addWidget(self.path_edit, 1); row.addWidget(browse) | |
| row2 = QHBoxLayout(); row2.addStretch(1); row2.addWidget(ok); row2.addWidget(cancel) | |
| lay = QVBoxLayout(self) | |
| lay.addWidget(QLabel("Select a .txt file to insert at cursor:")) | |
| lay.addLayout(row); lay.addLayout(row2) | |
| def _browse(self): | |
| path, _ = QFileDialog.getOpenFileName(self, "Insert File", "", "Text Files (*.txt);;All Files (*)") | |
| if path: self.path_edit.setText(path) | |
| def get_path(self): | |
| return self.path_edit.text().strip() | |
| class PasswordDialog(QDialog): | |
| def __init__(self, title="Enter Password", confirm=False, parent=None): | |
| super().__init__(parent) | |
| self.setWindowTitle(title); self.setMinimumWidth(340) | |
| self.pw_edit = QLineEdit(); self.pw_edit.setEchoMode(QLineEdit.Password) | |
| ok = QPushButton("OK"); cancel = QPushButton("Cancel") | |
| ok.clicked.connect(self.accept); cancel.clicked.connect(self.reject) | |
| lay = QVBoxLayout(self); lay.addWidget(QLabel("Password:")); lay.addWidget(self.pw_edit) | |
| if confirm: | |
| self.confirm_edit = QLineEdit() | |
| self.confirm_edit.setEchoMode(QLineEdit.Password) | |
| lay.addWidget(QLabel("Confirm:")); lay.addWidget(self.confirm_edit) | |
| else: self.confirm_edit = None | |
| row = QHBoxLayout(); row.addStretch(1); row.addWidget(ok); row.addWidget(cancel) | |
| lay.addLayout(row) | |
| def get_password(self): | |
| return self.pw_edit.text() | |
| def passwords_match(self): | |
| if self.confirm_edit is None: | |
| return True | |
| return self.pw_edit.text() == self.confirm_edit.text() | |
| class PreviewWindow(QDialog): | |
| def __init__(self, pages, session_password, parent=None): | |
| super().__init__(parent) | |
| self.setWindowTitle("Preview Pages") | |
| self.resize(1000, 780) | |
| self.pages = pages | |
| self.idx = 0 | |
| self.session_password = session_password | |
| self._build() | |
| def _build(self): | |
| self.page_label = QLabel("") | |
| self.pass_label = QLabel(f"Session key: {self.session_password}") | |
| self.image_label = QLabel() | |
| self.image_label.setAlignment(Qt.AlignCenter) | |
| self.image_label.setFrameShape(QFrame.StyledPanel) | |
| self.image_label.setMinimumSize(860, 620) | |
| self.prev_btn = QPushButton("◀ Previous") | |
| self.next_btn = QPushButton("Next ▶") | |
| self.prev_btn.clicked.connect(self.prev_page) | |
| self.next_btn.clicked.connect(self.next_page) | |
| top = QHBoxLayout() | |
| top.addWidget(QLabel("Preview session")); top.addStretch(1); top.addWidget(self.pass_label) | |
| nav = QHBoxLayout() | |
| nav.addWidget(self.prev_btn); nav.addStretch(1) | |
| nav.addWidget(self.page_label); nav.addStretch(1) | |
| nav.addWidget(self.next_btn) | |
| lay = QVBoxLayout(self) | |
| lay.addLayout(top); lay.addWidget(self.image_label, 1); lay.addLayout(nav) | |
| self.refresh() | |
| def refresh(self): | |
| if not self.pages: | |
| self.page_label.setText("0 / 0"); self.image_label.setText("No pages"); return | |
| self.idx = max(0, min(self.idx, len(self.pages) - 1)) | |
| self.page_label.setText(f"{self.idx + 1} / {len(self.pages)}") | |
| pix = QPixmap.fromImage(self.pages[self.idx]) | |
| self.image_label.setPixmap(pix.scaled(self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) | |
| def resizeEvent(self, e): | |
| super().resizeEvent(e); self.refresh() | |
| def prev_page(self): | |
| self.idx -= 1; self.refresh() | |
| def next_page(self): | |
| self.idx += 1; self.refresh() | |
| def closeEvent(self, e): | |
| for img in self.pages: | |
| try: | |
| raw = bytearray(img.bits().asstring(img.sizeInBytes())) | |
| secure_zero(raw) | |
| except Exception: | |
| pass | |
| self.pages.clear() | |
| super().closeEvent(e) | |
| class SecureTextEdit(QTextEdit): | |
| def __init__(self, parent=None): | |
| super().__init__(parent) | |
| self.auto_url_enabled = True | |
| self._last_text = "" | |
| self.tag_store = TagStore() | |
| self.setAcceptRichText(False) | |
| self.setLineWrapMode(QTextEdit.WidgetWidth) | |
| self.textChanged.connect(self.on_text_changed) | |
| def on_text_changed(self): | |
| if not self.auto_url_enabled: | |
| self._last_text = self.toPlainText(); return | |
| text = self.toPlainText() | |
| if len(text) <= len(self._last_text) or not text.endswith(" "): | |
| self._last_text = text; return | |
| parts = text.rstrip().split() | |
| if parts and is_url_token(parts[-1]): | |
| tag = self.tag_store.add("url", parts[-1]) | |
| parts[-1] = tag; new_text = " ".join(parts) + " " | |
| self.blockSignals(True); self.setPlainText(new_text) | |
| c = self.textCursor(); c.movePosition(c.End); self.setTextCursor(c) | |
| self.blockSignals(False) | |
| self._last_text = self.toPlainText() | |
| def contextMenuEvent(self, event): | |
| menu = self.createStandardContextMenu() | |
| menu.addSeparator() | |
| acts = [("Find Long Numbers", self.scan_long_numbers), | |
| ("Auto Store URLs", None), | |
| ("Insert File at Cursor", self.insert_file_at_cursor), | |
| ("Expand Tags → Plain Text", self.expand_tags_inline), | |
| ("Preview Pages", lambda: self.parent().open_preview()),] | |
| for label, slot in acts: | |
| if label == "Auto Store URLs": | |
| a = QAction(label, self, checkable=True) | |
| a.setChecked(self.auto_url_enabled) | |
| a.triggered.connect(self._toggle_auto_urls) | |
| else: a = QAction(label, self); a.triggered.connect(slot) | |
| menu.addAction(a) | |
| menu.addSeparator() | |
| export_menu = menu.addMenu("Export As") | |
| for lbl in ["Encrypted PNG Pack", "Encrypted ZIP", "Encrypted 7z", "Encrypted PDF", "Age Bundle", "PGP Bundle"]: | |
| a = QAction(lbl, self) | |
| a.triggered.connect(lambda checked=False, x=lbl: self.parent().export_as(x)) | |
| export_menu.addAction(a) | |
| menu.exec_(event.globalPos()) | |
| def _toggle_auto_urls(self): | |
| self.auto_url_enabled = not self.auto_url_enabled | |
| def expand_tags_inline(self): | |
| expanded = self.tag_store.replace_tags_in_text(self.toPlainText(), expand=True) | |
| self.blockSignals(True); self.setPlainText(expanded) | |
| self.blockSignals(False); self.tag_store.clear() | |
| def scan_long_numbers(self): | |
| nums = sorted(set(re.findall(r"\d{6,}", self.toPlainText()))) | |
| if not nums: | |
| QMessageBox.information(self, "Long Numbers", "No sequences longer than 5 digits found."); return | |
| dlg = NumberPickerDialog(nums, self) | |
| if dlg.exec_() == QDialog.Accepted and dlg.selected_value: | |
| tag = self.tag_store.add("number", dlg.selected_value) | |
| self._replace_first(dlg.selected_value, tag) | |
| QMessageBox.information(self, "Stored", f"Stored as {tag}") | |
| def _replace_first(self, old, new): | |
| text = self.toPlainText(); idx = text.find(old) | |
| if idx >= 0: | |
| text = text[:idx] + new + text[idx + len(old):] | |
| self.blockSignals(True); self.setPlainText(text); self.blockSignals(False) | |
| def insert_file_at_cursor(self): | |
| dlg = InsertPickerDialog(self) | |
| if dlg.exec_() != QDialog.Accepted: return | |
| path = dlg.get_path() | |
| if not path: return | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: content = f.read() | |
| tag = self.tag_store.add("inserted_file", content) | |
| cursor = self.textCursor(); cursor.insertText(tag) | |
| except Exception as e: | |
| QMessageBox.critical(self, "Insert Failed", str(e)) | |
| class EditorWindow(QMainWindow): | |
| def __init__(self): | |
| super().__init__() | |
| self.setWindowTitle(APP_NAME) | |
| self.resize(1240, 860) | |
| self.current_path = None | |
| self.preview = None | |
| self.editor = SecureTextEdit(self) | |
| self.setCentralWidget(self.editor) | |
| self._build_menus() | |
| self._build_toolbar() | |
| self._build_statusbar() | |
| self.editor.setFont(QFont("Segoe UI", 11)) | |
| def _build_menus(self): | |
| fm = self.menuBar().addMenu("File") | |
| for label, slot in [("Open", self.open_file), ("Save", self.save_file), ("Close", self.close_file)]: | |
| a = QAction(label, self); a.triggered.connect(slot); fm.addAction(a) | |
| em = self.menuBar().addMenu("Edit"); a = QAction("Find & Replace", self) | |
| a.triggered.connect(self.open_find_replace); em.addAction(a) | |
| exp_m = self.menuBar().addMenu("Export") | |
| for lbl in ["Encrypted PNG Pack", "Encrypted ZIP", "Encrypted 7z", "Encrypted PDF", "Age Bundle", "PGP Bundle"]: | |
| a = QAction(lbl, self); a.triggered.connect(lambda checked=False, x=lbl: self.export_as(x)); exp_m.addAction(a) | |
| sec_m = self.menuBar().addMenu("Secure") | |
| a = QAction("Open Secure Container…", self) | |
| a.triggered.connect(self.open_secure_container); sec_m.addAction(a) | |
| def _build_toolbar(self): | |
| tb = QToolBar("Main", self) | |
| tb.setIconSize(QSize(22, 22)) | |
| tb.setMovable(False) | |
| self.addToolBar(Qt.TopToolBarArea, tb) | |
| self.lock_btn = LockButton(self) | |
| tb.addWidget(self.lock_btn) | |
| spacer = QWidget(); spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) | |
| tb.addWidget(spacer) | |
| lbl = QLabel(" SECNTG EDITOR ") | |
| lbl.setStyleSheet("color: #c0392b; font-weight: bold; font-size: 13px;") | |
| tb.addWidget(lbl) | |
| def _build_statusbar(self): | |
| self.status = QStatusBar(self); self.setStatusBar(self.status); self.status.showMessage("Ready") | |
| self.editor.textChanged.connect(self._update_status) | |
| def _update_status(self): | |
| text = self.editor.toPlainText() | |
| words = len(text.split()) if text.strip() else 0 | |
| self.status.showMessage(f"Chars: {len(text)} | Words: {words} | " | |
| f"Tags stored: {len(self.editor.tag_store.items)}") | |
| def open_find_replace(self): | |
| FindReplaceDialog(self.editor, self).exec_() | |
| def open_file(self): | |
| path, _ = QFileDialog.getOpenFileName(self, "Open", "", "Text Files (*.txt);;All Files (*)") | |
| if not path: return | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: self.editor.setPlainText(f.read()) | |
| self.current_path = path | |
| except Exception as e: | |
| QMessageBox.critical(self, "Open Failed", str(e)) | |
| def save_file(self): | |
| if not self.current_path: | |
| path, _ = QFileDialog.getSaveFileName(self, "Save", "", "Text Files (*.txt)") | |
| if not path: return | |
| self.current_path = path | |
| try: | |
| with open(self.current_path, "w", encoding="utf-8") as f: f.write(self.editor.toPlainText()) | |
| except Exception as e: | |
| QMessageBox.critical(self, "Save Failed", str(e)) | |
| def close_file(self): | |
| text = self.editor.toPlainText(); zero_bytes(text.encode("utf-8")) | |
| self.editor.clear(); self.editor.tag_store.clear() | |
| self.current_path = None; self.lock_btn.set_locked(True) | |
| def make_pages_from_text(self, text: str): | |
| pages = [] | |
| if not text.strip(): | |
| img = QImage(PAGE_W, PAGE_H, QImage.Format_ARGB32); img.fill(QColor("#1f1f24")) | |
| return [img] | |
| font = QFont("Consolas", 11); fm = QFontMetrics(font) | |
| mw = PAGE_W - PAGE_MARGIN * 2; lpp = (PAGE_H - PAGE_MARGIN * 2) // fm.lineSpacing() | |
| lines = [] | |
| for p in text.split("\n"): | |
| if p == "": lines.append(""); continue | |
| wrapped = wrap_tokens_to_lines(tokenize_text(p), fm, mw) | |
| lines.extend(wrapped if wrapped else [""]) | |
| total = max(1, math.ceil(len(lines) / lpp)) | |
| for pidx in range(total): | |
| img = QImage(PAGE_W, PAGE_H, QImage.Format_ARGB32) | |
| img.fill(QColor("#202026")) | |
| pt = QPainter(img) | |
| pt.setRenderHint(QPainter.Antialiasing) | |
| pt.setFont(font) | |
| pt.setPen(Qt.NoPen); pt.setBrush(QColor("#151518")) | |
| pt.drawRoundedRect(18, 18, PAGE_W - 36, PAGE_H - 36, 18, 18) | |
| pt.setBrush(QColor("#8c1f1f")) | |
| pt.drawRoundedRect(18, 18, PAGE_W - 36, 10, 6, 6) | |
| pt.setPen(QColor("#e7e7e7")) | |
| start = pidx * lpp; y = PAGE_MARGIN | |
| for line in lines[start: start + lpp]: | |
| pt.drawText(PAGE_MARGIN, y, line); y += fm.lineSpacing() | |
| pt.end(); pages.append(img) | |
| return pages | |
| def open_preview(self): | |
| session_pw = base64.urlsafe_b64encode(os.urandom(12)).decode("ascii").rstrip("=") | |
| pages = self.make_pages_from_text(self.editor.toPlainText()) | |
| if HAS_CRYPTO: | |
| key = gen_key(); enc_pages = [] | |
| for img in pages: | |
| raw = img.bits().asstring(img.sizeInBytes()) | |
| nonce, ct = aesgcm_encrypt(key, raw, aad=session_pw.encode("utf-8")) | |
| enc_pages.append((nonce, ct)) | |
| self._preview_encrypted_pages = enc_pages | |
| kb = bytearray(key); secure_zero(kb) | |
| self.preview = PreviewWindow(pages, session_pw, self) | |
| self.preview.exec_() | |
| def open_secure_container(self): | |
| path, _ = QFileDialog.getOpenFileName(self, "Open Secure Container", "", "Secure Container (*.secntg);;All Files (*)") | |
| if not path: return | |
| dlg = PasswordDialog("Decrypt Container", confirm=False, parent=self) | |
| if dlg.exec_() != QDialog.Accepted: return | |
| pw = dlg.get_password() | |
| try: | |
| envelope = read_secure_container(path, pw) | |
| self.editor.setPlainText(envelope.get("text", "")) | |
| self.editor.tag_store.items = envelope.get("tags", []) | |
| self.status.showMessage("Container decrypted and loaded.") | |
| except Exception as e: | |
| QMessageBox.critical(self, "Decrypt Failed", str(e)) | |
| finally: | |
| zero_bytes(pw.encode("utf-8")) | |
| def export_as(self, label: str): | |
| folder = QFileDialog.getExistingDirectory(self, f"Export – {label}") | |
| if not folder: return | |
| dlg = PasswordDialog(f"Set password for {label}", confirm=True, parent=self) | |
| if dlg.exec_() != QDialog.Accepted: return | |
| if not dlg.passwords_match(): | |
| QMessageBox.warning(self, "Mismatch", "Passwords do not match."); return | |
| password = dlg.get_password() | |
| if not password: | |
| QMessageBox.warning(self, "Empty", "Password must not be empty."); return | |
| text = self.editor.toPlainText(); pages = self.make_pages_from_text(text) | |
| try: | |
| if label == "Encrypted PNG Pack": self._export_png_pack(folder, pages, text, password, label) | |
| elif label == "Encrypted ZIP": self._export_zip(folder, text, password, label) | |
| elif label == "Encrypted 7z": self._export_7z(folder, text, password, label) | |
| elif label == "Encrypted PDF": self._export_pdf(folder, pages, text, password, label) | |
| elif label == "Age Bundle": self._export_age(folder, text, password, label) | |
| elif label == "PGP Bundle": self._export_pgp(folder, text, password, label) | |
| else: | |
| QMessageBox.warning(self, "Unknown", f"Unknown export type: {label}"); return | |
| QMessageBox.information(self, "Export Complete", f"Exported to:\n{folder}") | |
| except Exception as e: | |
| QMessageBox.critical(self, "Export Failed", str(e)) | |
| finally: | |
| zero_bytes(password.encode("utf-8")) | |
| def _save_secure_container(self, dest_path: str, text: str, pages, password: str, label: str): | |
| page_b64 = [] | |
| for img in pages: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| tmp.close(); img.save(tmp.name) | |
| with open(tmp.name, "rb") as f: page_b64.append(base64.b64encode(f.read()).decode("ascii")) | |
| os.unlink(tmp.name) | |
| envelope = {"format": label, "text": text, "tags": self.editor.tag_store.items, "pages": page_b64,} | |
| write_secure_container(dest_path, envelope, password, label) | |
| pb = bytearray(json.dumps(envelope).encode()); secure_zero(pb) | |
| def _export_png_pack(self, folder, pages, text, password, label): | |
| base = os.path.join(folder, "png_pack"); os.makedirs(base, exist_ok=True) | |
| for i, img in enumerate(pages, 1): img.save(os.path.join(base, f"page_{i:03d}.png")) | |
| with open(os.path.join(base, "ntg_metadata.json"), "w") as f: f.write(self.editor.tag_store.to_json()) | |
| container_path = os.path.join(folder, "pack.secntg") | |
| self._save_secure_container(container_path, text, pages, password, label) | |
| def _export_zip(self, folder, text, password, label): | |
| zip_path = os.path.join(folder, "export.zip") | |
| pages = self.make_pages_from_text(text) | |
| plain_zip = zip_path + ".plain" | |
| with zipfile.ZipFile(plain_zip, "w", zipfile.ZIP_DEFLATED) as zf: | |
| zf.writestr("content.txt", text) | |
| zf.writestr("tags.json", self.editor.tag_store.to_json()) | |
| for i, img in enumerate(pages, 1): | |
| tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| tmp.close(); img.save(tmp.name) | |
| zf.write(tmp.name, f"page_{i:03d}.png"); os.unlink(tmp.name) | |
| with open(plain_zip, "rb") as f: raw_zip = f.read() | |
| os.unlink(plain_zip); salt = os.urandom(32) | |
| key = derive_key(password, salt) | |
| nonce, ct = aesgcm_encrypt(key, raw_zip, aad=label.encode()) | |
| with open(zip_path, "wb") as f: | |
| f.write(CONTAINER_MAGIC + struct.pack("<I", CONTAINER_VERSION) + salt + nonce + struct.pack("<I", len(label)) + label.encode() + ct) | |
| kb = bytearray(key); secure_zero(kb); rb = bytearray(raw_zip); secure_zero(rb) | |
| def _export_7z(self, folder, text, password, label): | |
| if not HAS_7Z: | |
| pages = self.make_pages_from_text(text) | |
| self._save_secure_container(os.path.join(folder, "export.secntg"), text, pages, password, label) | |
| QMessageBox.information(self, "Note", "py7zr not installed – saved as .secntg container instead.") | |
| return | |
| archive_path = os.path.join(folder, "export.7z") | |
| with py7zr.SevenZipFile(archive_path, mode="w", password=password) as z: | |
| import io | |
| z.writestr(text.encode("utf-8"), "content.txt") | |
| z.writestr(self.editor.tag_store.to_json().encode(), "tags.json") | |
| pages = self.make_pages_from_text(text) | |
| for i, img in enumerate(pages, 1): | |
| buf = io.BytesIO() | |
| tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| tmp.close(); img.save(tmp.name) | |
| with open(tmp.name, "rb") as f: z.writestr(f.read(), f"page_{i:03d}.png") | |
| os.unlink(tmp.name) | |
| def _export_pdf(self, folder, pages, text, password, label): | |
| pdf_path = os.path.join(folder, "export.pdf") | |
| if not HAS_FPDF: | |
| self._save_secure_container(os.path.join(folder, "export.secntg"), text, pages, password, label) | |
| QMessageBox.information(self, "Note", "fpdf2 not installed – saved as .secntg container instead.") | |
| return | |
| from fpdf import FPDF | |
| pdf = FPDF(orientation="P", unit="pt", format=(PAGE_W, PAGE_H)) | |
| pdf.set_auto_page_break(False) | |
| for img in pages: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| tmp.close(); img.save(tmp.name) | |
| pdf.add_page(); pdf.image(tmp.name, x=0, y=0, w=PAGE_W, h=PAGE_H) | |
| os.unlink(tmp.name) | |
| pdf.output(pdf_path) | |
| with open(pdf_path, "rb") as f: raw_pdf = f.read() | |
| os.unlink(pdf_path); salt = os.urandom(32); key = derive_key(password, salt) | |
| nonce, ct = aesgcm_encrypt(key, raw_pdf, aad=label.encode()) | |
| aad_b = label.encode() | |
| with open(pdf_path + ".secntg", "wb") as f: f.write(CONTAINER_MAGIC + struct.pack("<I", CONTAINER_VERSION) + salt + nonce + struct.pack("<I", len(aad_b)) + aad_b + ct) | |
| kb = bytearray(key); secure_zero(kb); rb = bytearray(raw_pdf); secure_zero(rb) | |
| def _export_age(self, folder, text, password, label): | |
| pages = self.make_pages_from_text(text); out_path = os.path.join(folder, "export.age") | |
| age_bin = shutil.which("age") | |
| if age_bin: | |
| import subprocess, io | |
| payload = json.dumps({"text": text, "tags": self.editor.tag_store.items}).encode("utf-8") | |
| tmp_in = tempfile.NamedTemporaryFile(delete=False, suffix=".json") | |
| tmp_in.write(payload); tmp_in.close() | |
| result = subprocess.run([age_bin, "--passphrase", "--output", out_path, tmp_in.name], input=f"{password}\n{password}\n".encode(), capture_output=True) | |
| os.unlink(tmp_in.name) | |
| if result.returncode != 0: | |
| raise RuntimeError(result.stderr.decode()) | |
| else: self._save_secure_container(out_path, text, pages, password, label) | |
| def _export_pgp(self, folder, text, password, label): | |
| pages = self.make_pages_from_text(text) | |
| out_path = os.path.join(folder, "export.pgp") | |
| gpg_bin = shutil.which("gpg") or shutil.which("gpg2") | |
| if gpg_bin: | |
| import subprocess | |
| payload = json.dumps({"text": text, "tags": self.editor.tag_store.items}).encode("utf-8") | |
| tmp_in = tempfile.NamedTemporaryFile(delete=False, suffix=".json") | |
| tmp_in.write(payload); tmp_in.close() | |
| result = subprocess.run([gpg_bin, "--batch", "--yes", "--passphrase", password, "--symmetric", "--cipher-algo", "AES256", "--output", out_path, tmp_in.name], capture_output=True) | |
| os.unlink(tmp_in.name) | |
| if result.returncode != 0: | |
| raise RuntimeError(result.stderr.decode()) | |
| else: | |
| self._save_secure_container(out_path, text, pages, password, label) | |
| QMessageBox.information(self, "Note", "gpg not found – saved as .secntg (AES-256-GCM) instead.") | |
| def main(): | |
| app = QApplication(sys.argv) | |
| app.setStyleSheet(load_stylesheet()) | |
| w = EditorWindow() | |
| w.show() | |
| sys.exit(app.exec_()) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment