↓快轉到主要內容
  1. 教學文章/

Python hashlib 實戰:檔案完整性、串流雜湊與安全比對

·8 分鐘· loading · loading · ·
Python Hashlib SHA-256 Checksum Security Standard-Library
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 127: 本文

featured

一. 前言:檔名一樣,不代表內容一樣
#

你下載了一個 8 GB 的模型檔。 檔名正確、大小看起來也差不多,程式卻在載入到 97% 時報錯。 這時候最有用的問題不是「要不要再下載一次」,而是:

我手上的每一個 byte,真的和發布者提供的檔案一樣嗎? 雜湊摘要(hash digest)就是用來回答這個問題的工具。 它會把任意長度的 bytes 映射成固定長度的指紋。 輸入只要改一點,摘要通常就會完全不同。 Python 標準庫的 hashlib 可以處理:

  • 計算字串或 bytes 的 SHA-256
  • 串流讀取大型檔案,不把整包資料塞進記憶體
  • 用 file_digest() 簡化檔案摘要
  • 建立一份可重複驗證的 checksum manifest
  • 用 compare_digest() 比對外部提供的摘要
  • 明確區分「完整性」和「來源可信度」 今天拍拍君會做出一個小型檔案驗證工具。 重點不是背 API,而是把邊界條件也處理好:演算法、路徑、格式、錯誤和驗證結果都要清楚。

二. 安裝:標準庫內建,先確認 Python 版本
#

hashlib 是 Python 標準庫,不需要另外安裝。

import hashlib

本文的基本範例可在現代 Python 3 執行。 其中 hashlib.file_digest() 是 Python 3.11 加入的便利函式。 先看看環境提供哪些演算法:

import hashlib
print(sorted(hashlib.algorithms_guaranteed))
print("sha256" in hashlib.algorithms_available)

兩個集合的意義不同:

  • algorithms_guaranteed:Python 保證跨平台可用的名稱
  • algorithms_available:目前這個直譯器與 OpenSSL 實際提供的名稱 如果工具要跨機器執行,優先選 guaranteed 集合裡的演算法。 不要在自己的 Mac 上看到某個名字,就假設所有 CI runner 都一定有。

三. 第一個 SHA-256:雜湊吃的是 bytes
#

最短的例子是:

from hashlib import sha256
payload = b"Daily Pypy"
digest = sha256(payload)
print(digest.digest())
print(digest.hexdigest())

digest() 回傳原始 bytes。 hexdigest() 回傳容易顯示、複製與寫進文字檔的十六進位字串。 SHA-256 的摘要是 32 bytes,所以 hex 形式會有 64 個字元:

from hashlib import sha256
h = sha256(b"Daily Pypy")
assert len(h.digest()) == 32
assert len(h.hexdigest()) == 64

如果手上是字串,要先決定編碼:

from hashlib import sha256
message = "拍拍君"
encoded = message.encode("utf-8")
print(sha256(encoded).hexdigest())

下面這段則會失敗:

# sha256("拍拍君")  # TypeError: Strings must be encoded before hashing

這不是麻煩人的限制。 同一段文字可能用 UTF-8、UTF-16 或其他編碼變成不同 bytes。 hashlib 要你先把規則說清楚,摘要才有可重現性。

四. update():分段餵資料,結果仍然一樣
#

Hash object 可以多次 update():

from hashlib import sha256
one_shot = sha256(b"Daily Pypy").hexdigest()
streamed = sha256()
streamed.update(b"Daily ")
streamed.update(b"Pypy")
assert streamed.hexdigest() == one_shot

多次 update 的結果,等於把 bytes 串起來後一次計算。 這個特性就是大型檔案串流處理的基礎。 如果要摘要多個欄位,不要只把它們直接相加;應使用明確序列化格式、長度前綴或固定 schema。 摘要忠實反映輸入 bytes,但資料邊界仍是應用程式的責任。

五. 演算法怎麼選:預設從 SHA-256 開始
#

hashlib 提供很多演算法,但不表示每一個都適合新設計。

演算法 摘要長度 新的完整性流程 備註
SHA-256 256 bits 推薦預設 相容性廣、容易和 CLI 工具交換
SHA-512 512 bits 可用 摘要更長
SHA3-256 256 bits 可用 SHA-3 家族
BLAKE2b 1–64 bytes 可用 可調輸出長度,也支援 keyed mode
MD5 128 bits 不建議 已知碰撞弱點,只適合相容舊 checksum
SHA-1 160 bits 不建議 已知碰撞弱點,不應用於新安全流程
一般檔案發布流程,用 SHA-256 就很合理:
import hashlib
def make_hasher(name: str = "sha256"):
    allowed = {"sha256", "sha512", "sha3_256", "blake2b"}
    if name not in allowed:
        raise ValueError(f"unsupported digest algorithm: {name}")
    return hashlib.new(name)

白名單比直接接受任意字串更容易維護。 它也讓 manifest 的政策穩定,不會因為某台機器多提供一個 OpenSSL alias 就悄悄改變。 usedforsecurity=False 不是「把弱演算法變安全」的開關。 它只是在受限制環境中,明確表示某個舊演算法不是用於安全情境。 新設計不要靠它替 MD5 或 SHA-1 找理由。

六. 串流計算大型檔案:固定記憶體就夠了
#

最直覺但不理想的寫法是:

from hashlib import sha256
from pathlib import Path
data = Path("model.bin").read_bytes()
print(sha256(data).hexdigest())

小檔案沒問題,但 8 GB 檔案會先要求大約 8 GB 記憶體。 其實雜湊只需要一塊一塊讀:

from __future__ import annotations
import hashlib
from pathlib import Path
CHUNK_SIZE = 1024 * 1024
def hash_file(path: Path, algorithm: str = "sha256") -> str:
    hasher = hashlib.new(algorithm)
    with path.open("rb") as source:
        while chunk := source.read(CHUNK_SIZE):
            hasher.update(chunk)
    return hasher.hexdigest()
print(hash_file(Path("model.bin")))

這段程式不論檔案是 20 MB 或 20 GB,主要 buffer 都維持在 1 MiB 左右。 Chunk size 不會改變摘要:

from pathlib import Path
target = Path("model.bin")
assert hash_file(target) == hash_file(target, "sha256")

它主要影響 I/O 呼叫次數與吞吐量。 通常選 256 KiB 到數 MiB,再用自己的儲存裝置量測即可。 不要為了追求一個神奇 chunk size,把清楚的程式改成迷宮。

七. file_digest():Python 3.11+ 的便利入口
#

Python 3.11 起,可以把開啟的 binary file object 交給 file_digest():

from __future__ import annotations
import hashlib
from pathlib import Path
def hash_file_311(path: Path) -> str:
    with path.open("rb") as source:
        return hashlib.file_digest(source, "sha256").hexdigest()

它讓意圖很直接:對這個檔案計算指定摘要。 仍然要注意幾件事:

  1. 檔案必須以 binary reading mode 開啟。
  2. 呼叫後不要依賴 file object 的目前位置。
  3. 如果要支援 Python 3.10,保留上一節的 update() 迴圈。
  4. 摘要完成後,檔案仍可能被其他程序修改。 最後一點是 TOCTOU(檢查時間與使用時間不同)問題。 如果安全流程要求「驗證的就是接下來要處理的內容」,應該在同一個受控流程中保留檔案描述符,或把驗證通過的內容移入不可變的暫存區。

八. 驗證外部摘要:先驗格式,再安全比對
#

假設發布者提供了一串 SHA-256:

9f86d081884c7d659a2feaa0c55ad015...

我們先做嚴格格式驗證:

from __future__ import annotations
import re
SHA256_RE = re.compile(r"[0-9a-fA-F]{64}\Z")
def normalize_sha256(value: str) -> str:
    candidate = value.strip().lower()
    if not SHA256_RE.fullmatch(candidate):
        raise ValueError("expected exactly 64 hexadecimal characters")
    return candidate

接著使用 hmac.compare_digest():

from __future__ import annotations
import hmac
from pathlib import Path
def verify_file(path: Path, expected: str) -> bool:
    normalized = normalize_sha256(expected)
    actual = hash_file(path)
    return hmac.compare_digest(actual, normalized)

compare_digest() 避免一般字串比對依內容提早結束,適合安全敏感的摘要或 MAC 比對。 兩邊要使用相同型別;這裡統一成 ASCII hex 字串。 這和 Python secrets 實戰 的角色不同。 secrets 負責產生不可預測 token;本篇的摘要是從既有檔案計算出來的確定值。 兩者都會用到安全比對,但用途完全不一樣。

九. 建立 checksum manifest:一批檔案,一份規格
#

單一檔案可以手動比對。 如果 release 有模型、設定、詞表與索引檔,最好建立 manifest。 先定義 JSON 格式:

{
  "version": 1,
  "algorithm": "sha256",
  "files": {
    "config.json": "<64-char hex>",
    "model.bin": "<64-char hex>"
  }
}

產生 manifest:

from __future__ import annotations
import json
from pathlib import Path
def build_manifest(root: Path, names: list[str]) -> dict[str, object]:
    files: dict[str, str] = {}
    for name in sorted(names):
        relative = Path(name)
        if relative.is_absolute() or ".." in relative.parts:
            raise ValueError(f"unsafe relative path: {name}")
        target = root / relative
        if not target.is_file():
            raise FileNotFoundError(target)
        files[relative.as_posix()] = hash_file(target)
    return {
        "version": 1,
        "algorithm": "sha256",
        "files": files,
    }
root = Path("release")
manifest = build_manifest(root, ["config.json", "model.bin"])
Path("checksums.json").write_text(
    json.dumps(manifest, indent=2, sort_keys=True) + "\n",
    encoding="utf-8",
)

排序有兩個好處:

  • 同一批檔案每次輸出的順序一致
  • Git diff 和 code review 比較容易讀 路徑檢查則避免 manifest 指向 ../private.key 或絕對路徑。 Checksum 工具也在處理輸入邊界,不只是跑一個 hash function。

十. 驗證 manifest:分清 missing、mismatch 與 ok
#

驗證工具不應只回傳一個模糊的 False。 維運時需要知道是檔案不存在,還是內容不同。

from __future__ import annotations
import hmac
from pathlib import Path
def verify_manifest(
    root: Path,
    manifest: dict[str, object],
) -> dict[str, str]:
    if manifest.get("version") != 1:
        raise ValueError("unsupported manifest version")
    if manifest.get("algorithm") != "sha256":
        raise ValueError("unsupported manifest algorithm")
    entries = manifest.get("files")
    if not isinstance(entries, dict):
        raise ValueError("manifest files must be an object")
    results: dict[str, str] = {}
    for name, raw_expected in entries.items():
        if not isinstance(name, str) or not isinstance(raw_expected, str):
            raise ValueError("manifest entries must map strings to strings")
        relative = Path(name)
        if relative.is_absolute() or ".." in relative.parts:
            results[name] = "unsafe-path"
            continue
        target = root / relative
        if not target.is_file():
            results[name] = "missing"
            continue
        expected = normalize_sha256(raw_expected)
        actual = hash_file(target)
        results[name] = (
            "ok" if hmac.compare_digest(actual, expected) else "mismatch"
        )
    return results

載入並印出結果:

import json
from pathlib import Path
manifest = json.loads(Path("checksums.json").read_text(encoding="utf-8"))
results = verify_manifest(Path("release"), manifest)
for name, status in results.items():
    print(f"{status:12} {name}")
if any(status != "ok" for status in results.values()):
    raise SystemExit(1)

非零 exit code 很重要。 它讓 CI、部署腳本與 shell pipeline 可以真正阻止損壞的 artifact 繼續往下走。

十一. 測試:一個 byte 改變就必須失敗
#

完整性工具最重要的測試,不是「成功時印得漂不漂亮」。 而是損壞、缺檔與危險路徑是否真的被擋住。

from pathlib import Path
def test_round_trip(tmp_path: Path) -> None:
    (tmp_path / "model.bin").write_bytes(b"PYPY-v1")
    manifest = build_manifest(tmp_path, ["model.bin"])
    assert verify_manifest(tmp_path, manifest) == {"model.bin": "ok"}
def test_modified_file_is_rejected(tmp_path: Path) -> None:
    target = tmp_path / "model.bin"
    target.write_bytes(b"PYPY-v1")
    manifest = build_manifest(tmp_path, ["model.bin"])
    target.write_bytes(b"PYPY-v2")
    assert verify_manifest(tmp_path, manifest) == {"model.bin": "mismatch"}
def test_missing_file_is_reported(tmp_path: Path) -> None:
    manifest = {
        "version": 1,
        "algorithm": "sha256",
        "files": {"missing.bin": "0" * 64},
    }
    assert verify_manifest(tmp_path, manifest) == {"missing.bin": "missing"}
def test_parent_path_is_rejected(tmp_path: Path) -> None:
    manifest = {
        "version": 1,
        "algorithm": "sha256",
        "files": {"../secret.txt": "0" * 64},
    }
    assert verify_manifest(tmp_path, manifest) == {
        "../secret.txt": "unsafe-path"
    }

如果使用 symlink,還要先定義政策:

  • 跟隨連結並驗證目標內容?
  • 完全拒絕 symlink?
  • 只允許解析後仍位於 root 內的連結? 沒有唯一答案,但不能讓行為靠意外決定。 面對不可信 manifest 時,最保守的工具通常直接拒絕 symlink。

十二. 常見陷阱:摘要不是萬能保證
#

1. Hash 不會證明發布者身分
#

如果攻擊者能同時替換檔案和網站上的 checksum,兩者仍然會一致。 Hash 能檢查內容有沒有變,但不能單獨證明 checksum 是誰發布的。 需要來源驗證時,應搭配:

  • HTTPS 傳輸
  • 數位簽章
  • 可信任的 release channel
  • 簽章金鑰與輪替政策

2. 一般 hash 不適合直接存密碼
#

不要把 sha256(password) 當成密碼儲存方案。 密碼需要專門設計、可調成本、帶 salt 的 password hashing/KDF。 hashlib 雖然也提供 PBKDF2 與 scrypt,但那是另一個完整主題。 本篇的目標是檔案完整性,不是密碼資料庫設計。

3. MD5/SHA-1 舊 checksum 要標示風險
#

有些舊資料集只提供 MD5。 你可以為相容性計算它,但不要把「比對成功」寫成強安全保證。 最好同時產生新的 SHA-256 manifest,逐步淘汰舊格式。

4. Metadata 不在檔案摘要裡
#

一般檔案 hash 只涵蓋檔案內容。 檔名、權限、owner、mtime 不會自動包含在摘要裡。 如果部署流程也要驗證這些資訊,必須把它們明確放進已簽章的 manifest。

5. 驗證前後檔案可能改變
#

多程序環境中,通過驗證的路徑可能立刻被替換。 高安全需求下,要設計原子發布、唯讀 artifact store 或 descriptor-based workflow。 別把一次 True 當成宇宙永久保固。

十三. 實務檢查清單
#

交付檔案完整性流程前,拍拍君會確認:

  • 明確固定演算法,預設使用 SHA-256
  • 大檔案用串流或 file_digest(),不整包讀入記憶體
  • 外部 digest 先檢查長度與 hex 格式
  • 安全敏感比對使用 compare_digest()
  • Manifest 有版本、演算法與固定路徑格式
  • 拒絕絕對路徑與 .. traversal
  • 清楚區分 missing、mismatch、unsafe-path 與 ok
  • 失敗時 CLI 回傳非零 exit code
  • 測試至少包含單 byte 修改、缺檔與危險路徑
  • 需要來源可信度時,另外加入數位簽章

結語
#

hashlib 的 API 很小,但可靠的完整性流程不只是一行 sha256()。 真正重要的是:

  1. 先把文字編碼、資料邊界和演算法定清楚。
  2. 大檔案用 update() 或 file_digest() 串流處理。
  3. 對外部摘要做嚴格格式驗證,再安全比對。
  4. 多檔案用有版本的 manifest,並把路徑視為不可信輸入。
  5. 記住 hash 驗證內容,簽章才負責證明來源。 下次模型、備份或 release artifact 壞掉時,不用盯著檔名猜人生。 讓 bytes 自己出示指紋,拍拍君比較相信證據。🔍

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 127: 本文

相關文章

Python shlex 實戰:安全拆解 Shell 參數、Quote 與迷你指令語法
·6 分鐘· loading · loading
Python Shlex Shell Cli Security Standard-Library
Python secrets 實戰:安全產生 Token、密碼與一次性連結
·7 分鐘· loading · loading
Python Secrets Security Token Standard-Library Web
Python struct 實戰:二進位資料打包、解析與檔案格式入門
·7 分鐘· loading · loading
Python Struct Binary Standard-Library Developer-Tools
Python mmap 實戰:記憶體映射、隨機存取與大型檔案搜尋
·7 分鐘· loading · loading
Python Mmap Memory-Mapped-File Filesystem Performance Standard-Library
Python unicodedata 實戰:文字正規化、搜尋與去重
·6 分鐘· loading · loading
Python Unicodedata Unicode Text-Normalization Search Standard-Library
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools