Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản14 phút đọc

Băm mật khẩu với tham số chi phí có thể duy trì

Đọc bản ghi Argon2id thật, đo ba cấu hình và dùng cập nhật có điều kiện để lần băm lại không ghi đè mật khẩu vừa đặt lại.

Cổng giấy màu ô liu và những đường nối tối dẫn tới tủ lưu trữ nhỏ trên nền kem

Bản ghi băm mật khẩu là một định dạng dữ liệu được giữ lâu dài. Mã đăng nhập triển khai hôm nay có thể phải xác minh những bản ghi tạo từ nhiều năm trước, thay đổi tham số của chúng và phối hợp với yêu cầu đổi mật khẩu đang chạy ở nơi khác. Chọn một thuật toán tốn nhiều tài nguyên mới chỉ giải quyết một phần công việc.

Bài này giả định bạn đã biết Python và SQL cơ bản. Ta sẽ dùng thư viện Argon2 đang được duy trì, đọc bản ghi thật, đo ba cấu hình cụ thể và tái hiện lỗi ghi dữ liệu cũ trong lúc đặt lại mật khẩu. Các ví dụ chỉ xác minh và chuyển đổi bản ghi băm. Chúng không triển khai đầy đủ endpoint đăng nhập, không cấp phiên và không xác định chính sách mật khẩu khi tạo tài khoản.

Hiểu bản ghi đang được lưu

Bộ xác minh cần kiểm tra mật khẩu được cung cấp mà không lưu một bản sao có thể khôi phục. Hàm băm tổng quát chạy nhanh như SHA-256 không phù hợp cho việc lưu mật khẩu thông thường: chi phí thử từng ứng viên vốn rất thấp. Hướng dẫn lưu mật khẩu của OWASP khuyến nghị hàm băm chuyên dụng và đưa ra các cấu hình Argon2id với bộ nhớ, số vòng lặp và mức song song được chỉ rõ.

Salt là đầu vào ngẫu nhiên được tạo cho từng lần băm. Nó được lưu cùng kết quả, không phải mật khẩu thứ hai hay khóa mã hóa bí mật. Với các bản ghi có salt khác nhau, kẻ tấn công phải tính riêng từng ứng viên cho mỗi bản ghi. Nó không tự biến mật khẩu yếu thành mật khẩu khó đoán. Hãy để thư viện cấp cao tạo salt, thay vì suy ra salt từ tên người dùng hoặc đem salt cố định của mẫu thử vào mã vận hành.

Bản ghi Argon2id thật được tách thành thuật toán, phiên bản, tham số bộ nhớ và vòng lặp, salt ngẫu nhiên cùng tag được dẫn xuất.Mở ảnh kích thước đầy đủ ↗

Bản ghi này được tạo từ mật khẩu giả định công khai trong ví dụ chạy được. Salt và tag là đầu ra thật, không phải giá trị tự đặt để vẽ hình.

Chuỗi biểu diễn chứa thuật toán, phiên bản và tham số cần cho việc xác minh. Trong m=65536,t=3,p=1, bộ nhớ được tính bằng KiB, nên 65536 tương ứng 64 MiB. Số vòng lặp và mức song song có ý nghĩa riêng; đây không phải một con số “chi phí” dùng chung cho mọi thuật toán. Hãy giữ nguyên toàn bộ chuỗi trong kho dữ liệu. Ví dụ này không cần tách chuỗi thành các cột, còn cắt ngắn nó sẽ làm hỏng việc xác minh.

Khai báo chính sách rõ ràng và dùng bộ xác minh của thư viện

Tạo thư mục làm việc riêng và cài các phiên bản đã ghi nhận. Phiên bản binding cũng được ghim vì nó chứa triển khai native đã dùng trong lượt thực thi này.

python3 -m venv .venv
. .venv/bin/activate
pip install argon2-cffi==25.1.0 argon2-cffi-bindings==26.1.0

Lưu đoạn sau thành hashing.py. Cấu hình cũ dùng 19456 KiB, hai vòng lặp và một lane, khớp lựa chọn Argon2id 19 MiB được OWASP liệt kê khi bài được kiểm tra. Cấu hình đích dùng 65536 KiB, ba vòng lặp và một lane. Đây là cấu hình của thí nghiệm, không phải khuyến nghị chung cho mọi hệ thống hay cấu hình ít bộ nhớ bốn lane trong RFC.

from dataclasses import dataclass
from argon2 import PasswordHasher, Parameters, Type, extract_parameters
from argon2.exceptions import VerifyMismatchError

# Explicit experiment policies, not universal deployment recommendations.
LEGACY = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1,
                        salt_len=16, hash_len=32, type=Type.ID)
CURRENT = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=1,
                         salt_len=16, hash_len=32, type=Type.ID)
APPROVED_OLD = Parameters(type=Type.ID, version=19, salt_len=16, hash_len=32,
                          time_cost=2, memory_cost=19456, parallelism=1)

@dataclass(frozen=True)
class CheckedPassword:
    outcome: str
    replacement: str | None = None


def verify_and_prepare(encoded, password):
    # encoded comes from our account store, never from the login request.
    try:
        CURRENT.verify(encoded, password)
    except VerifyMismatchError:
        return CheckedPassword('mismatch')
    # Invalid stored hashes and other operational failures propagate.
    if not CURRENT.check_needs_rehash(encoded):
        return CheckedPassword('current')
    # A parameter difference is not an ordering of cryptographic strength.
    if extract_parameters(encoded) != APPROVED_OLD:
        return CheckedPassword('policy-review')
    return CheckedPassword('rehash-ready', CURRENT.hash(password))


def replace_if_unchanged(connection, account_id, observed, replacement):
    changed = connection.execute(
        'UPDATE accounts SET password_hash = ? '
        'WHERE id = ? AND password_hash = ?',
        (replacement, account_id, observed),
    ).rowcount
    connection.commit()
    return changed == 1


if __name__ == '__main__':
    # Public, synthetic fixture values. Never print real login passwords.
    password = 'only a demonstration password'
    first = CURRENT.hash(password)
    second = CURRENT.hash(password)
    print('first record:', first)
    print('second record:', second)
    print('same password, different records:', first != second)
    print('current match:', verify_and_prepare(first, password).outcome)
    print('wrong password:', verify_and_prepare(first, 'wrong').outcome)
    old = LEGACY.hash(password)
    prepared = verify_and_prepare(old, password)
    print('old policy:', prepared.outcome)
    print('replacement verifies:', CURRENT.verify(prepared.replacement, password))
    print('replacement needs this policy:', CURRENT.check_needs_rehash(prepared.replacement))
    print('old hasher also flags the new record:', LEGACY.check_needs_rehash(first))

Chạy python hashing.py. Hai lần băm cùng mật khẩu mẫu có salt và bản ghi khác nhau; cả hai vẫn xác minh được bằng cùng mật khẩu. Việc xác minh dùng tham số nằm trong từng bản ghi đã lưu. Tạo đối tượng CURRENT không buộc bản ghi cũ phải được xác minh bằng mức bộ nhớ mới.

API của thư viện trả true khi xác minh thành công và ném ngoại lệ ở các trường hợp khác. Hàm chỉ bắt lỗi mật khẩu không khớp. Bản ghi lưu trữ sai định dạng hay lỗi bên trong triển khai băm vẫn là lỗi vận hành. Lớp HTTP thật cần xử lý chúng mà không lộ stack trace hoặc phân loại nội bộ cho bên gọi; coi mọi sự cố lưu trữ là “sai mật khẩu” sẽ che khuất một vấn đề khác.

Bộ xác minh giả định ứng dụng kiểm soát chuỗi băm. Hãy lấy nó từ kho tài khoản. Đừng để yêu cầu đăng nhập tự chọn một bản ghi dạng chuỗi tùy ý cùng các tham số tài nguyên của nó. Ví dụ đưa mật khẩu dạng chuỗi qua bước mã hóa UTF-8 của thư viện; nó không trim, cắt ngắn hay chuẩn hóa chuỗi đó. Chính sách chuẩn hóa khi tạo tài khoản và khi đăng nhập phải thống nhất, thay vì âm thầm thay đổi lúc chuyển đổi bản ghi.

Tham số khác không đồng nghĩa với nâng cấp

check_needs_rehash hỏi bản ghi có khớp tham số của đối tượng băm này không. Nó không xếp hạng mức an toàn của hai bản ghi. Trong ví dụ, đối tượng dùng cấu hình cũ cũng báo rằng bản ghi mới cần băm lại. Nếu luôn hiểu câu trả lời đó là “làm mạnh hơn”, một lần rollback ứng dụng hoặc triển khai nhiều phiên bản song song có thể chuyển bản ghi theo hướng ngược lại.

Vì vậy, mã chỉ phê duyệt một bộ tham số nguồn đã biết cho việc chuyển đổi tự động. Bản ghi được xác minh thành công nhưng dùng bộ tham số khác sẽ trả policy-review và không có giá trị thay thế. Trường hợp này bao gồm bản ghi có nhiều vòng lặp hơn cấu hình đích. Người vận hành phải quyết định cách xử lý. Đây là danh sách chuyển đổi nhỏ của thí nghiệm, không phải thuật toán tổng quát để so sánh độ mạnh của các bản ghi băm mật khẩu.

Đầu ra Python thật phân biệt mật khẩu khớp cấu hình hiện tại, mật khẩu sai, chuyển đổi được phê duyệt và việc đối tượng cũ cũng đánh dấu bản ghi mới.Mở ảnh kích thước đầy đủ ↗

Đầu ra làm rõ vấn đề chiều chuyển đổi khi so sánh tham số. Chỉ cờ cần băm lại mang giá trị true chưa đủ để cho phép chuyển đổi.

Hãy băm lại sau khi xác minh thành công, vì lúc ấy mật khẩu người dùng cung cấp đang có sẵn. Băm chuỗi bản ghi cũ sẽ bảo vệ chính chuỗi ấy như một mật khẩu mới; nó không nâng cấp bộ xác minh của mật khẩu ban đầu. Hướng dẫn argon2-cffi mô tả cách băm lại khi đăng nhập. Tài liệu nâng cấp mật khẩu của Django là ví dụ thực tế ở cấp framework và nói rõ rằng thay đổi tham số có thể tăng hoặc giảm các thiết lập.

Tài khoản mà chủ sở hữu không đăng nhập trở lại sẽ không đi qua nhánh này. Kế hoạch chuyển đổi cần quyết định riêng cho những bản ghi đó, chẳng hạn đưa người dùng qua luồng đặt lại mật khẩu. Giữ hỗ trợ xác minh mà bản ghi hiện có cần cho đến khi xử lý xong giai đoạn chuyển tiếp. Bỏ một thuật toán cũ khỏi ứng dụng không có nghĩa là mọi bản ghi đã được chuyển đổi.

Đo cấu hình trong môi trường phải vận hành nó

Hướng dẫn chọn tham số Argon2 và RFC 9106 xem xét bộ nhớ, lane và thời gian chấp nhận được cùng nhau. Những cấu hình ví dụ của chúng không cho biết hệ thống của bạn chịu được bao nhiêu lượt đăng nhập đồng thời.

Lưu đoạn dưới thành measure.py. Nó chạy một lần khởi động không tính vào kết quả, rồi băm tuần tự bảy lần cho mỗi cấu hình. Mỗi lần đều nhận salt ngẫu nhiên mới từ thư viện. Khoảng thời gian đo bao gồm lời gọi băm cấp cao, không phải phép đo riêng vòng lặp mật mã bên trong.

from time import perf_counter_ns
from statistics import median
from importlib.metadata import version
import json, platform
from argon2 import PasswordHasher, Type

rows = []
for memory, iterations in [(19456, 2), (32768, 2), (65536, 3)]:
    hasher = PasswordHasher(memory_cost=memory, time_cost=iterations,
                            parallelism=1, salt_len=16, hash_len=32, type=Type.ID)
    hasher.hash('synthetic timing input')  # One warm-up, excluded.
    samples = []
    for _ in range(7):
        start = perf_counter_ns()
        hasher.hash('synthetic timing input')
        samples.append((perf_counter_ns() - start) / 1_000_000)
    rows.append({'memoryKiB': memory, 'iterations': iterations, 'parallelism': 1,
                 'milliseconds': samples, 'medianMilliseconds': median(samples)})
print(json.dumps({'python': platform.python_version(), 'machine': platform.machine(),
                  'library': version('argon2-cffi'),
                  'bindings': version('argon2-cffi-bindings'), 'rows': rows,
                  'scope': 'One process, sequential calls, seven samples per policy. '
                           'No concurrent login traffic, attacker benchmark or capacity estimate.'}, indent=2))

Biểu đồ bảy lần băm thật ở mỗi cấu hình hiển thị từng quan sát và trung vị, kèm tham số bộ nhớ, vòng lặp và mức song song.Mở ảnh kích thước đầy đủ ↗

Một tiến trình Python 3.12.3 trên Linux x86-64, argon2-cffi 25.1.0 và bindings 26.1.0. Hoạt động khác trên máy không được kiểm soát. Đây là quan sát cục bộ, không phải benchmark của kẻ tấn công hay ước lượng sức chứa đăng nhập.

Trong lượt chạy này, cấu hình đích có trung vị khoảng 375 mili giây, với các quan sát từ khoảng 227 đến 491 mili giây. Độ phân tán đó là lý do cần giữ từng mẫu và đo lại trong điều kiện triển khai dự định. Nó không phải lý do lấy mẫu nhanh nhất làm lời hứa cho dịch vụ. Xác minh, chuyển đổi thành công và đăng nhập bằng bản ghi đã đúng chính sách cũng làm lượng công việc khác nhau: chuyển đổi có thể cần cả xác minh lẫn một lần băm mới.

Bộ nhớ còn là vấn đề đồng thời, không chỉ là tham số của một lời gọi. Nhiều phép tính 64 MiB cùng chạy có thể vượt ngân sách của container nhỏ trước khi tính ứng dụng, bộ cấp phát và các yêu cầu khác. Bộ nhớ cấu hình cho Argon2 không phải trần RSS của tiến trình đã được đo. Hãy giới hạn công việc đắt bằng hàng đợi, số tác vụ đồng thời và biện pháp chống lạm dụng phù hợp, rồi kiểm tra chúng trên dịch vụ thật. Chương trình tuần tự này không thực thi lưu lượng hay tình huống áp lực bộ nhớ đó.

Đừng để băm lại từ dữ liệu cũ ghi đè mật khẩu vừa đặt lại

Giá trị thay thế được chuẩn bị từ một bản ghi cụ thể. Yêu cầu khác có thể sửa bản ghi ấy trước khi giá trị thay thế được ghi. UPDATE không có điều kiện kiểm tra phiên bản có thể khôi phục mật khẩu cũ sau khi lần đặt lại đã commit.

replace_if_unchanged chỉ cập nhật tài khoản còn giữ đúng chuỗi băm đã quan sát. Hàm tự commit trên kết nối SQLite dành riêng cho nó; đừng truyền kết nối đang có công việc khác chưa commit. Lưu bài tập riêng này thành race.py và chạy khi hashing.py đã có sẵn:

from pathlib import Path
from tempfile import TemporaryDirectory
import sqlite3
from hashing import CURRENT, LEGACY, verify_and_prepare, replace_if_unchanged

old_password = 'old synthetic password'
new_password = 'new synthetic password'
with TemporaryDirectory() as directory:
    database = Path(directory) / 'accounts.sqlite'
    login = sqlite3.connect(database)
    reset = sqlite3.connect(database)
    try:
        login.execute('CREATE TABLE accounts (id INTEGER PRIMARY KEY, password_hash TEXT NOT NULL)')
        for guarded in (False, True):
            original = LEGACY.hash(old_password)
            login.execute('DELETE FROM accounts')
            login.execute('INSERT INTO accounts VALUES (?, ?)', (7, original))
            login.commit()
            observed = login.execute('SELECT password_hash FROM accounts WHERE id = 7').fetchone()[0]
            prepared = verify_and_prepare(observed, old_password)
            assert prepared.outcome == 'rehash-ready'

            # Another connection commits a password reset after our read.
            reset_record = CURRENT.hash(new_password)
            reset.execute('UPDATE accounts SET password_hash = ? WHERE id = 7', (reset_record,))
            reset.commit()
            if guarded:
                changed = replace_if_unchanged(login, 7, observed, prepared.replacement)
            else:
                changed = login.execute('UPDATE accounts SET password_hash = ? WHERE id = 7',
                                        (prepared.replacement,)).rowcount == 1
                login.commit()
            stored = reset.execute('SELECT password_hash FROM accounts WHERE id = 7').fetchone()[0]
            old_matches = verify_and_prepare(stored, old_password).outcome == 'current'
            new_matches = verify_and_prepare(stored, new_password).outcome == 'current'
            print('guarded:', guarded, 'changed:', changed,
                  'old matches:', old_matches, 'new matches:', new_matches)
            assert (changed, old_matches, new_matches) == ((False, False, True) if guarded else (True, True, False))
    finally:
        reset.close()
        login.close()

Hai kết nối SQLite thật xen kẽ một lần đọc, lần đặt lại đã commit và lần ghi bị trì hoãn. Đây là thứ tự được điều khiển rõ ràng, không phải kiểm thử tải hay quan sát chờ khóa. Nhánh không an toàn khôi phục mật khẩu cũ. Nhánh cập nhật có điều kiện không thay đổi hàng nào và giữ nguyên mật khẩu vừa đặt lại.

Kết quả thật từ hai kết nối đối chiếu lần ghi vô điều kiện khôi phục mật khẩu cũ với lần ghi có điều kiện giữ nguyên mật khẩu đã đặt lại.Mở ảnh kích thước đầy đủ ↗

Lần ghi có điều kiện không thành công cho biết bản ghi đã đọc không còn hiện hành. Nó không chỉ ra thao tác cạnh tranh nào đã làm thay đổi bản ghi.

Một lần băm lại khác thành công cũng có thể khiến UPDATE có điều kiện ảnh hưởng không hàng nào. Hãy đọc lại và làm theo chính sách nhất quán thông tin xác thực của ứng dụng; đừng thử lại mù quáng giá trị thay thế đã cũ hay gọi mọi xung đột là sai mật khẩu. Hàm bảo vệ giá trị lưu trữ khỏi lần ghi đè cũ này. Nó không biến việc xác minh mật khẩu, cấp phiên và thu hồi phiên sau đặt lại thành một thao tác nguyên tử. Điều đó cần thiết kế riêng, thường có số thế hệ thông tin xác thực được chính sách phiên kiểm tra.

Giữ các loại kết quả có ý nghĩa rõ ràng

Những kết quả dưới đây đến từ hàm và các đối chứng lưu trữ đã chạy. current và policy-review có nghĩa là mật khẩu được cung cấp khớp với bản ghi đã quan sát. Chúng không nói tài khoản còn hoạt động, yếu tố xác thực thứ hai đã đạt hay lúc này nên cấp phiên.

Bảng kết quả phân biệt mật khẩu sai, chính sách hiện tại, chuyển đổi được phê duyệt, chính sách chưa biết, bản ghi sai định dạng và thay đổi lưu trữ xen giữa.Mở ảnh kích thước đầy đủ ↗

Thay chính sách băm cũng có thể làm đổi thời gian phản hồi. Tài liệu Django cảnh báo rằng nhiều thuật toán cùng tồn tại và cách xử lý tài khoản không tồn tại có thể để lộ khác biệt thời gian. Hàm nhỏ này không giải quyết việc dò tài khoản hay giới hạn tần suất. Hãy dùng nó để xem rõ ranh giới lưu trữ và chuyển đổi, rồi đánh giá luồng xác thực bao quanh một cách riêng biệt.

Trước khi triển khai thay đổi tham số, hãy liệt kê những chính sách đang được lưu, những chính sách mỗi phiên bản ứng dụng có thể xác minh và các chiều chuyển đổi được phê duyệt chính xác. Đo cấu hình đích trên môi trường triển khai, thử xung đột với đặt lại mật khẩu và với lần chuyển đổi khác, rồi quyết định cách xử lý tài khoản không quay lại. Tham số chi phí có thể duy trì là một chính sách bạn vận hành và chuyển đổi được mà không làm mất trạng thái tài khoản mà nó phải bảo vệ.

Nguồn tham khảo & đọc thêm

  1. OWASP: Password Storage Cheat Sheet
  2. argon2-cffi: How to hash a password
  3. argon2-cffi: API reference
  4. argon2-cffi: Choosing parameters
  5. RFC 9106: Argon2 memory-hard function
  6. Django 5.2: Password management
← Về trang bài viết

Hình minh họa

100%