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
|