food-records/app/crypto.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

171 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Формат зашифрованного файла базы (.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