Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
"""Формат зашифрованного файла базы (.fmdb).
|
||
|
||
Вся база — один JSON-документ. На диск он попадает так:
|
||
|
||
json → utf-8 → gzip → AES-256-GCM → файл
|
||
|
||
Ключ выводится из пароля через scrypt. Заголовок файла (37 байт) передаётся
|
||
в GCM как AAD, поэтому подменить параметры KDF или соль незаметно нельзя —
|
||
расшифровка упадёт с InvalidTag так же, как при неверном пароле.
|
||
|
||
смещение размер содержимое
|
||
0 4 магия b"FMDB"
|
||
4 1 версия формата
|
||
5 1 id функции выведения ключа (1 = scrypt)
|
||
6 3 параметры scrypt: log2(n), r, p
|
||
9 16 salt
|
||
25 12 nonce
|
||
37 … ciphertext + тег GCM (16 байт)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import gzip
|
||
import os
|
||
import struct
|
||
|
||
from cryptography.exceptions import InvalidTag
|
||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||
|
||
MAGIC = b"FMDB"
|
||
FORMAT_VERSION = 1
|
||
KDF_SCRYPT = 1
|
||
|
||
SALT_SIZE = 16
|
||
NONCE_SIZE = 12
|
||
KEY_SIZE = 32
|
||
HEADER_SIZE = 37
|
||
|
||
# n=2**15 — примерно 100 мс на вывод ключа. Достаточно, чтобы перебор паролей
|
||
# был дорогим, и незаметно при открытии базы.
|
||
SCRYPT_LOG_N = 15
|
||
SCRYPT_R = 8
|
||
SCRYPT_P = 1
|
||
|
||
_HEADER_STRUCT = struct.Struct(f"!4sBBBBB{SALT_SIZE}s{NONCE_SIZE}s")
|
||
assert _HEADER_STRUCT.size == HEADER_SIZE
|
||
|
||
|
||
class VaultError(Exception):
|
||
"""Базовая ошибка работы с файлом базы."""
|
||
|
||
|
||
class WrongPassword(VaultError):
|
||
"""Пароль неверный либо файл повреждён/подделан.
|
||
|
||
Намеренно не различаем эти случаи: по тексту ошибки не должно быть видно,
|
||
угадана ли часть пароля.
|
||
"""
|
||
|
||
|
||
class UnsupportedFormat(VaultError):
|
||
"""Файл создан более новой версией программы либо это вообще не база."""
|
||
|
||
|
||
class Header:
|
||
"""Разобранный заголовок файла."""
|
||
|
||
__slots__ = ("version", "kdf_id", "log_n", "r", "p", "salt", "nonce", "raw")
|
||
|
||
def __init__(
|
||
self,
|
||
version: int,
|
||
kdf_id: int,
|
||
log_n: int,
|
||
r: int,
|
||
p: int,
|
||
salt: bytes,
|
||
nonce: bytes,
|
||
) -> None:
|
||
self.version = version
|
||
self.kdf_id = kdf_id
|
||
self.log_n = log_n
|
||
self.r = r
|
||
self.p = p
|
||
self.salt = salt
|
||
self.nonce = nonce
|
||
self.raw = _HEADER_STRUCT.pack(
|
||
MAGIC, version, kdf_id, log_n, r, p, salt, nonce
|
||
)
|
||
|
||
@classmethod
|
||
def new(cls) -> "Header":
|
||
"""Свежий заголовок со случайными salt и nonce."""
|
||
return cls(
|
||
version=FORMAT_VERSION,
|
||
kdf_id=KDF_SCRYPT,
|
||
log_n=SCRYPT_LOG_N,
|
||
r=SCRYPT_R,
|
||
p=SCRYPT_P,
|
||
salt=os.urandom(SALT_SIZE),
|
||
nonce=os.urandom(NONCE_SIZE),
|
||
)
|
||
|
||
@classmethod
|
||
def parse(cls, blob: bytes) -> "Header":
|
||
if len(blob) < HEADER_SIZE:
|
||
raise UnsupportedFormat("Файл слишком короткий, это не база.")
|
||
|
||
magic, version, kdf_id, log_n, r, p, salt, nonce = _HEADER_STRUCT.unpack(
|
||
blob[:HEADER_SIZE]
|
||
)
|
||
if magic != MAGIC:
|
||
raise UnsupportedFormat("Это не файл базы (неверная сигнатура).")
|
||
if version > FORMAT_VERSION:
|
||
raise UnsupportedFormat(
|
||
f"Файл создан более новой версией программы (формат {version}). "
|
||
"Обнови приложение."
|
||
)
|
||
if kdf_id != KDF_SCRYPT:
|
||
raise UnsupportedFormat(f"Неизвестный способ выведения ключа: {kdf_id}.")
|
||
# Верхняя граница защищает от файла, который просит гигабайты памяти.
|
||
if not 10 <= log_n <= 22:
|
||
raise UnsupportedFormat(f"Недопустимый параметр scrypt: log2(n)={log_n}.")
|
||
if not 1 <= r <= 32 or not 1 <= p <= 16:
|
||
raise UnsupportedFormat("Недопустимые параметры scrypt.")
|
||
|
||
return cls(version, kdf_id, log_n, r, p, salt, nonce)
|
||
|
||
|
||
def derive_key(password: str, header: Header) -> bytes:
|
||
"""Вывести ключ шифрования из пароля по параметрам из заголовка."""
|
||
kdf = Scrypt(
|
||
salt=header.salt,
|
||
length=KEY_SIZE,
|
||
n=2**header.log_n,
|
||
r=header.r,
|
||
p=header.p,
|
||
)
|
||
return kdf.derive(password.encode("utf-8"))
|
||
|
||
|
||
def encrypt(plaintext: bytes, password: str) -> bytes:
|
||
"""Сжать и зашифровать открытые данные, вернуть содержимое файла целиком.
|
||
|
||
Каждый вызов берёт новые salt и nonce, поэтому одни и те же данные дают
|
||
разные байты. Проверять «изменилось ли что-то» по этому результату нельзя —
|
||
для этого storage хранит хеш открытого JSON.
|
||
"""
|
||
header = Header.new()
|
||
key = derive_key(password, header)
|
||
packed = gzip.compress(plaintext, compresslevel=9, mtime=0)
|
||
ciphertext = AESGCM(key).encrypt(header.nonce, packed, header.raw)
|
||
return header.raw + ciphertext
|
||
|
||
|
||
def decrypt(blob: bytes, password: str) -> bytes:
|
||
"""Расшифровать содержимое файла, вернуть открытые данные."""
|
||
header = Header.parse(blob)
|
||
key = derive_key(password, header)
|
||
try:
|
||
packed = AESGCM(key).decrypt(header.nonce, blob[HEADER_SIZE:], header.raw)
|
||
except InvalidTag as exc:
|
||
raise WrongPassword("Неверный пароль или файл повреждён.") from exc
|
||
|
||
try:
|
||
return gzip.decompress(packed)
|
||
except (OSError, EOFError) as exc:
|
||
# Сюда попасть почти невозможно: GCM уже подтвердил целостность.
|
||
raise VaultError("Данные расшифровались, но распаковать их не удалось.") from exc
|