|
16 | 16 | from pathlib import Path |
17 | 17 |
|
18 | 18 | from cryptography.hazmat.backends import default_backend |
19 | | -from cryptography.hazmat.primitives import hashes |
20 | 19 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes |
21 | | -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC |
22 | 20 |
|
23 | 21 | from .app_paths import get_output_databases_dir |
24 | 22 | from .database_filters import should_skip_source_database |
|
28 | 26 |
|
29 | 27 | # SQLite文件头 |
30 | 28 | SQLITE_HEADER = b"SQLite format 3\x00" |
| 29 | +PAGE_SIZE = 4096 |
| 30 | +KEY_SIZE = 32 |
| 31 | +SALT_SIZE = 16 |
| 32 | +IV_SIZE = 16 |
| 33 | +HMAC_SIZE = 64 |
| 34 | +# WeChat 4.x SQLCipher/WCDB pages reserve IV + HMAC at the tail. |
| 35 | +# When exporting to plain SQLite, do not keep encrypted IV/HMAC bytes in output pages. |
| 36 | +RESERVE_SIZE = IV_SIZE + HMAC_SIZE |
| 37 | + |
| 38 | + |
| 39 | +def _derive_mac_key(enc_key: bytes, salt: bytes) -> bytes: |
| 40 | + """Derive SQLCipher/WCDB page HMAC key.""" |
| 41 | + mac_salt = bytes(b ^ 0x3A for b in salt) |
| 42 | + return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SIZE) |
| 43 | + |
| 44 | + |
| 45 | +def _derive_sqlcipher_enc_key(key_material: bytes, salt: bytes) -> bytes: |
| 46 | + """Derive AES enc_key from SQLCipher passphrase/base key.""" |
| 47 | + return hashlib.pbkdf2_hmac("sha512", key_material, salt, 256000, dklen=KEY_SIZE) |
| 48 | + |
| 49 | + |
| 50 | +def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes: |
| 51 | + offset = SALT_SIZE if page_num == 1 else 0 |
| 52 | + data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE |
| 53 | + mac = hmac.new(mac_key, digestmod=hashlib.sha512) |
| 54 | + mac.update(page[offset:data_end]) |
| 55 | + mac.update(page_num.to_bytes(4, "little")) |
| 56 | + return mac.digest() |
| 57 | + |
| 58 | + |
| 59 | +def _resolve_page1_key_material(key_material: bytes, page1: bytes) -> tuple[bytes, bytes, str] | None: |
| 60 | + """Detect whether input key is raw enc_key or SQLCipher passphrase by page-1 HMAC.""" |
| 61 | + if len(page1) < PAGE_SIZE: |
| 62 | + return None |
| 63 | + |
| 64 | + salt = page1[:SALT_SIZE] |
| 65 | + stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE] |
| 66 | + candidates = [ |
| 67 | + ("raw_enc_key", key_material, _derive_mac_key(key_material, salt)), |
| 68 | + ] |
| 69 | + |
| 70 | + derived_key = _derive_sqlcipher_enc_key(key_material, salt) |
| 71 | + candidates.append(("sqlcipher_passphrase", derived_key, _derive_mac_key(derived_key, salt))) |
| 72 | + |
| 73 | + for mode, enc_key, mac_key in candidates: |
| 74 | + if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)): |
| 75 | + return enc_key, mac_key, mode |
| 76 | + |
| 77 | + return None |
| 78 | + |
| 79 | + |
| 80 | +def _decrypt_page(enc_key: bytes, page: bytes, page_num: int) -> bytes: |
| 81 | + iv = page[PAGE_SIZE - RESERVE_SIZE: PAGE_SIZE - RESERVE_SIZE + IV_SIZE] |
| 82 | + offset = SALT_SIZE if page_num == 1 else 0 |
| 83 | + encrypted_page = page[offset: PAGE_SIZE - RESERVE_SIZE] |
| 84 | + |
| 85 | + cipher = Cipher( |
| 86 | + algorithms.AES(enc_key), |
| 87 | + modes.CBC(iv), |
| 88 | + backend=default_backend(), |
| 89 | + ) |
| 90 | + decryptor = cipher.decryptor() |
| 91 | + decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize() |
| 92 | + |
| 93 | + # Plain SQLite pages do not carry SQLCipher/WCDB IV/HMAC reserve bytes. |
| 94 | + # Keep page size stable by zero-filling the reserve tail. |
| 95 | + if page_num == 1: |
| 96 | + return SQLITE_HEADER + decrypted_page + (b"\x00" * RESERVE_SIZE) |
| 97 | + return decrypted_page + (b"\x00" * RESERVE_SIZE) |
31 | 98 |
|
32 | 99 |
|
33 | 100 | def _normalize_account_name(name: str) -> str: |
@@ -398,113 +465,57 @@ def _finalize(success: bool, error: str = "") -> bool: |
398 | 465 | result["copied_as_sqlite"] = True |
399 | 466 | return _finalize(True) |
400 | 467 |
|
401 | | - # 提取salt (前16字节) |
402 | | - salt = encrypted_data[:16] |
403 | | - |
404 | | - # 计算mac_salt (salt XOR 0x3a) |
405 | | - mac_salt = bytes(b ^ 0x3a for b in salt) |
406 | | - |
407 | | - # 使用PBKDF2-SHA512派生密钥 |
408 | | - kdf = PBKDF2HMAC( |
409 | | - algorithm=hashes.SHA512(), |
410 | | - length=32, |
411 | | - salt=salt, |
412 | | - iterations=256000, |
413 | | - backend=default_backend() |
414 | | - ) |
415 | | - derived_key = kdf.derive(self.key_bytes) |
416 | | - |
417 | | - # 派生MAC密钥 |
418 | | - mac_kdf = PBKDF2HMAC( |
419 | | - algorithm=hashes.SHA512(), |
420 | | - length=32, |
421 | | - salt=mac_salt, |
422 | | - iterations=2, |
423 | | - backend=default_backend() |
424 | | - ) |
425 | | - mac_key = mac_kdf.derive(derived_key) |
426 | | - |
427 | | - # 解密数据 |
| 468 | + page1 = encrypted_data[:PAGE_SIZE] |
| 469 | + resolved_key_material = _resolve_page1_key_material(self.key_bytes, page1) |
| 470 | + if resolved_key_material is None: |
| 471 | + _append_failed_page(1, "hmac") |
| 472 | + result["total_pages"] = int(len(encrypted_data) // PAGE_SIZE) |
| 473 | + result["failed_pages"] = 1 |
| 474 | + logger.warning("Page 1 HMAC verification failed; key does not match database: %s", db_path) |
| 475 | + return _finalize(False, "key_mismatch") |
| 476 | + |
| 477 | + enc_key, mac_key, key_mode = resolved_key_material |
| 478 | + result["key_mode"] = key_mode |
| 479 | + logger.info("Page 1 HMAC verification passed: mode=%s path=%s", key_mode, db_path) |
| 480 | + |
428 | 481 | decrypted_data = bytearray() |
429 | | - decrypted_data.extend(SQLITE_HEADER) |
430 | | - |
431 | | - page_size = 4096 |
432 | | - iv_size = 16 |
433 | | - hmac_size = 64 # SHA512的HMAC是64字节 |
434 | | - |
435 | | - # 计算保留区域大小 (对齐到AES块大小) |
436 | | - reserve_size = iv_size + hmac_size |
437 | | - if reserve_size % 16 != 0: |
438 | | - reserve_size = ((reserve_size // 16) + 1) * 16 |
439 | | - |
440 | | - total_pages = len(encrypted_data) // page_size |
| 482 | + total_pages = (len(encrypted_data) + PAGE_SIZE - 1) // PAGE_SIZE |
441 | 483 | successful_pages = 0 |
442 | 484 | failed_pages = 0 |
443 | 485 | result["total_pages"] = int(total_pages) |
444 | | - |
445 | | - # 逐页解密 |
| 486 | + |
446 | 487 | for cur_page in range(total_pages): |
447 | | - start = cur_page * page_size |
448 | | - end = start + page_size |
449 | | - page = encrypted_data[start:end] |
450 | | - |
451 | | - page_num = cur_page + 1 # 页面编号从1开始 |
452 | | - |
453 | | - if len(page) < page_size: |
454 | | - logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes") |
| 488 | + page_num = cur_page + 1 |
| 489 | + start = cur_page * PAGE_SIZE |
| 490 | + page = encrypted_data[start:start + PAGE_SIZE] |
| 491 | + if not page: |
455 | 492 | break |
456 | | - |
457 | | - # 确定偏移量:第一页(cur_page == 0)需要跳过salt |
458 | | - offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16 |
459 | | - |
460 | | - # 提取存储的HMAC |
461 | | - hmac_start = page_size - reserve_size + iv_size |
462 | | - hmac_end = hmac_start + hmac_size |
463 | | - stored_hmac = page[hmac_start:hmac_end] |
464 | | - |
465 | | - # 按照wechat-dump-rs的方式验证HMAC |
466 | | - data_end = page_size - reserve_size + iv_size |
467 | | - hmac_data = page[offset:data_end] |
468 | | - |
469 | | - # 分步计算HMAC:先更新数据,再更新页面编号 |
470 | | - mac = hmac.new(mac_key, digestmod=hashlib.sha512) |
471 | | - mac.update(hmac_data) # 包含加密数据+IV |
472 | | - mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序) |
473 | | - expected_hmac = mac.digest() |
474 | | - |
475 | | - if stored_hmac != expected_hmac: |
476 | | - logger.warning(f"页面 {page_num} HMAC验证失败") |
| 493 | + if len(page) < PAGE_SIZE: |
| 494 | + logger.warning( |
| 495 | + "Page %s is short: %s bytes; padding to %s bytes", |
| 496 | + page_num, |
| 497 | + len(page), |
| 498 | + PAGE_SIZE, |
| 499 | + ) |
| 500 | + page = page + (b"\x00" * (PAGE_SIZE - len(page))) |
| 501 | + |
| 502 | + stored_hmac = page[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE] |
| 503 | + expected_hmac = _compute_page_hmac(mac_key, page, page_num) |
| 504 | + if not hmac.compare_digest(stored_hmac, expected_hmac): |
| 505 | + logger.warning("Page %s HMAC verification failed", page_num) |
477 | 506 | failed_pages += 1 |
478 | 507 | _append_failed_page(page_num, "hmac") |
479 | 508 | continue |
480 | | - |
481 | | - # 提取IV和加密数据用于AES解密 |
482 | | - iv = page[page_size - reserve_size:page_size - reserve_size + iv_size] |
483 | | - encrypted_page = page[offset:page_size - reserve_size] |
484 | | - |
485 | | - # AES-CBC解密 |
| 509 | + |
486 | 510 | try: |
487 | | - cipher = Cipher( |
488 | | - algorithms.AES(derived_key), |
489 | | - modes.CBC(iv), |
490 | | - backend=default_backend() |
491 | | - ) |
492 | | - decryptor = cipher.decryptor() |
493 | | - decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize() |
494 | | - |
495 | | - # 按照wechat-dump-rs的方式重组页面数据 |
496 | | - decrypted_data.extend(decrypted_page) |
497 | | - decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域 |
498 | | - |
| 511 | + decrypted_data.extend(_decrypt_page(enc_key, page, page_num)) |
499 | 512 | successful_pages += 1 |
500 | | - |
501 | 513 | except Exception as e: |
502 | | - logger.error(f"页面 {page_num} AES解密失败: {e}") |
| 514 | + logger.error("Page %s AES decryption failed: %s", page_num, e) |
503 | 515 | failed_pages += 1 |
504 | 516 | _append_failed_page(page_num, "aes", str(e)) |
505 | 517 | continue |
506 | 518 |
|
507 | | - logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages} 页") |
508 | 519 | result["successful_pages"] = int(successful_pages) |
509 | 520 | result["failed_pages"] = int(failed_pages) |
510 | 521 |
|
|
0 commit comments