185 lines
7.7 KiB
Python
Raw Permalink 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.

"""SQLCipher 4 raw-key reader. Native AES; authenticated pages and WAL commits.
Only 4096-byte pages / 80 reserved bytes / SHA512 / raw AES keys are supported.
No source database is opened for writing. Unknown formats fail closed.
"""
import ctypes as C
import hashlib
import hmac
import os
import platform
import sqlite3
import struct
from functools import lru_cache
from pathlib import Path
PAGE = 4096
RESERVE = 80
@lru_cache(maxsize=1)
def common_crypto():
lib = C.CDLL('/usr/lib/system/libcommonCrypto.dylib')
fn = lib.CCCrypt
fn.argtypes = [C.c_uint, C.c_uint, C.c_uint, C.c_void_p, C.c_size_t,
C.c_void_p, C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
return fn
def aes_cbc(data, key, iv, encrypt=False):
if len(key) != 32 or len(iv) != 16 or len(data) % 16:
raise ValueError('无效的 AES 数据长度')
output = C.create_string_buffer(len(data) + 16)
if platform.system() == 'Darwin':
fn = common_crypto()
moved = C.c_size_t()
code = fn(0 if encrypt else 1, 0, 0, key, len(key), iv, data, len(data), output, len(output), C.byref(moved))
if code or moved.value != len(data):
raise ValueError('本机 AES 解密失败')
return output.raw[:moved.value]
if platform.system() == 'Windows':
# Windows native crypto also permits fixture tests without pip packages.
lib = C.WinDLL('bcrypt')
handle, key_handle = C.c_void_p(), C.c_void_p()
def check(result):
if result:
raise ValueError('本机 AES 操作失败')
check(lib.BCryptOpenAlgorithmProvider(C.byref(handle), C.c_wchar_p('AES'), None, 0))
try:
mode = C.create_unicode_buffer('ChainingModeCBC')
check(lib.BCryptSetProperty(handle, C.c_wchar_p('ChainingMode'), mode, C.sizeof(mode), 0))
length, written = C.c_ulong(), C.c_ulong()
check(lib.BCryptGetProperty(handle, C.c_wchar_p('ObjectLength'), C.byref(length), 4, C.byref(written), 0))
obj = C.create_string_buffer(length.value)
check(lib.BCryptGenerateSymmetricKey(handle, C.byref(key_handle), obj, length.value, key, len(key), 0))
ivbuf = C.create_string_buffer(iv, 16)
fn = lib.BCryptEncrypt if encrypt else lib.BCryptDecrypt
check(fn(key_handle, data, len(data), None, ivbuf, 16, output, len(output), C.byref(written), 0))
return output.raw[:written.value]
finally:
if key_handle.value:
lib.BCryptDestroyKey(key_handle)
lib.BCryptCloseAlgorithmProvider(handle, 0)
raise ValueError('原生解密目前支持 macOS / Windows')
def hmac_key(key, salt):
return hashlib.pbkdf2_hmac('sha512', key, bytes(x ^ 0x3a for x in salt), 2, 32)
def decrypt_page(page, number, key, mac_key):
if len(page) != PAGE:
raise ValueError('数据库页大小不匹配(仅支持 SQLCipher 4 / 4096)')
if not any(page) and number != 1:
return page
start = 16 if number == 1 else 0
payload, iv = page[start:-RESERVE], page[-RESERVE:-64]
expected = hmac.new(mac_key, payload + iv + struct.pack('<I', number), hashlib.sha512).digest()
if not hmac.compare_digest(expected, page[-64:]):
raise ValueError('页认证失败:密钥、格式不匹配或副本损坏;没有跳过错误页')
plain = aes_cbc(payload, key, iv)
return (b'SQLite format 3\x00' if number == 1 else b'') + plain + bytes(RESERVE)
def matches(first_page, key):
try:
plain = decrypt_page(first_page, 1, key, hmac_key(key, first_page[:16]))
return plain[16:18] == b'\x10\x00' and plain[20] == RESERVE
except ValueError:
return False
def wal_checksum(data, initial=(0, 0), endian='<'):
if len(data) % 8:
raise ValueError('WAL 校验数据长度错误')
a, b = initial
for x, y in struct.iter_unpack(endian + 'II', data):
a = (a + x + b) & 0xffffffff
b = (b + y + a) & 0xffffffff
return a, b
def wal_frames(path):
"""Validate the complete WAL chain; return only frames through last commit."""
if not path.exists() or path.stat().st_size == 0:
return [], None
frames, commit, db_pages = [], 0, None
with path.open('rb') as file:
header = file.read(32)
if len(header) != 32:
raise ValueError('WAL 头不完整,请退出微信后重新备份')
magic, version, size = struct.unpack('>III', header[:12])
if magic not in (0x377f0682, 0x377f0683) or version != 3007000 or size != PAGE:
raise ValueError('不支持的 WAL 格式')
endian = '<' if magic == 0x377f0682 else '>'
checksum = wal_checksum(header[:24], endian=endian)
if checksum != struct.unpack('>II', header[24:32]):
raise ValueError('WAL 头校验失败')
while True:
offset = file.tell()
frame_header = file.read(24)
if not frame_header:
break
page = file.read(PAGE)
if len(frame_header) != 24 or len(page) != PAGE:
raise ValueError('WAL 尾帧不完整,请重新取得稳定副本')
if frame_header[8:16] != header[16:24]:
raise ValueError('WAL salt 不匹配,副本可能跨越了数据库轮换')
checksum = wal_checksum(frame_header[:8] + page, checksum, endian)
if checksum != struct.unpack('>II', frame_header[16:24]):
raise ValueError('WAL 帧校验失败,请重新备份')
number, count = struct.unpack('>II', frame_header[:8])
if not number or number > 10_000_000 or count > 10_000_000:
raise ValueError('WAL 页编号无效')
frames.append((offset + 24, number))
if count:
commit, db_pages = len(frames), count
return frames[:commit], db_pages
def decrypt_database(source, destination, key):
source, destination = Path(source), Path(destination)
with source.open('rb') as file:
first = file.read(PAGE)
mac_key = hmac_key(key, first[:16])
if not matches(first, key):
raise ValueError('密钥或加密参数不匹配')
if source.stat().st_size % PAGE:
raise ValueError('源数据库大小不完整')
frames, count = wal_frames(Path(str(source) + '-wal'))
created = False
try:
with source.open('rb') as file, destination.open('xb') as out:
created = True
try:
destination.chmod(0o600)
except OSError:
pass
number = 0
while page := file.read(PAGE):
number += 1
out.write(decrypt_page(page, number, key, mac_key))
if frames:
with Path(str(source) + '-wal').open('rb') as wal:
for offset, number in frames:
wal.seek(offset)
plain = decrypt_page(wal.read(PAGE), number, key, mac_key)
out.seek((number - 1) * PAGE)
out.write(plain)
out.truncate(count * PAGE)
# WAL is now replayed into this standalone, private output only.
out.seek(18)
out.write(b'\x01\x01')
db = sqlite3.connect(destination.resolve().as_uri() + '?mode=ro&immutable=1', uri=True)
try:
result = db.execute('PRAGMA quick_check').fetchall()
if result != [('ok',)]:
raise ValueError('解密后的数据库完整性检查未通过')
finally:
db.close()
except Exception:
if created:
destination.unlink(missing_ok=True)
raise
return {'wal_frames': len(frames), 'pages': destination.stat().st_size // PAGE}