food-market/app/crypto.py

171 lines
6.5 KiB
Python
Raw Normal View History

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