Skip to content

Instantly share code, notes, and snippets.

@lastforkbender
Created June 23, 2026 15:17
Show Gist options
  • Select an option

  • Save lastforkbender/34a9e663da2c393a971405550f19e3d6 to your computer and use it in GitHub Desktop.

Select an option

Save lastforkbender/34a9e663da2c393a971405550f19e3d6 to your computer and use it in GitHub Desktop.
FBK - SECNTG EDITOR
# /-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/
# \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\
# / 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