SCRAM: _generate_salted_password reimplements PBKDF2 in Python; hashlib.pbkdf2_hmac is ~30x faster and bit-identical

Đang mở Phù hợp với người mới
#1,357 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
78/100
Loại issue
Tái cấu trúc
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
postgresql, python

Hướng nghiên cứu

Bắt đầu trong asyncpg/protocol/scram.pyx tại SCRAMAuthentication._generate_salted_password() và xem xét vòng lặp PBKDF2 hiện có cùng helper _bytes_xor. Thay thế việc dẫn xuất ở cấp Python bằng phần tương đương của thư viện chuẩn, sau đó xác minh rằng đầu ra vẫn giống hệt theo từng bit đối với các đầu vào SCRAM SHA-256 hiện có và xác nhận rằng helper không được sử dụng không có tham chiếu nào khác.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Summary

SCRAMAuthentication._generate_salted_password() implements PBKDF2-HMAC-SHA256 as a Python-level loop. hashlib.pbkdf2_hmac('sha256', ...) computes the identical value in C and is ~27–31× faster. Since this runs on the event loop on every new connection, it shows up as a measurable stall for workloads that open connections often (pool overflow, bursty traffic, short-lived tasks).

Where

asyncpg/protocol/scram.pyx (current master):

ui = hmac.new(p, s + b'\x00\x00\x00\x01', self.DIGEST)
u = ui.digest()
for x in range(iterations - 1):
    ui = hmac.new(p, ui.digest(), hashlib.sha256)
    u = self._bytes_xor(u, ui.digest())
return u

That is exactly the "Hi" function from RFC 5802, i.e. PBKDF2-HMAC-SHA256 with dkLen = hLen — which the stdlib already provides.

_bytes_xor is a Python generator over zip(), so each of the 4095 iterations allocates two digests and XORs 32 bytes one byte at a time.

Measurements

PostgreSQL's default scram_iterations is 4096.

Environment Python loop hashlib.pbkdf2_hmac Factor
Debian container, x86_64, CPython 3.11 28.6 ms 0.915 ms 31×
macOS 26, arm64, CPython 3.12 9.28 ms 0.337 ms 27×

Output is bit-identical:

import hashlib, hmac, time

def asyncpg_way(p, s, it):
    ui = hmac.new(p, s + b'\x00\x00\x00\x01', hashlib.sha256)
    u = ui.digest()
    for _ in range(it - 1):
        ui = hmac.new(p, ui.digest(), hashlib.sha256)
        u = bytes(x ^ y for x, y in zip(u, ui.digest()))
    return u

p, s, iters = b"correct horse battery staple", b"0123456789abcdef", 4096
assert asyncpg_way(p, s, iters) == hashlib.pbkdf2_hmac("sha256", p, s, iters)

for name, fn in (("loop", asyncpg_way),
                 ("pbkdf2_hmac", lambda p, s, i: hashlib.pbkdf2_hmac("sha256", p, s, i))):
    fn(p, s, iters)
    t = time.perf_counter(); n = 0
    while time.perf_counter() - t < 2.0:
        fn(p, s, iters); n += 1
    print(f"{name:12} {(time.perf_counter()-t)/n*1000:7.3f} ms")
Why it matters in practice

We hit this while profiling event-loop stalls in a FastAPI/SQLAlchemy service with password_encryption = scram-sha-256. py-spy attributed ~16 % of total event-loop CPU to hmac.py with no application frame above it — the caller is invisible because scram.pyx is Cython-compiled, so only the Python hmac.new frames show up. It took a while to identify.

The trigger was connection churn: our SQLAlchemy pool was discarding overflow connections, so ~255 connections/minute were being established, each paying ~28.6 ms of PBKDF2 synchronously on the event loop — roughly 7 seconds of blocked loop per minute.

Fixing the churn on our side was the main remedy, and I'm not suggesting asyncpg is responsible for that. But a connection establishment costing 28.6 ms of CPU rather than 0.9 ms makes any such situation ~30× worse than it needs to be, and it's on the loop.

Suggested change
cdef _generate_salted_password(self, str password, bytes salt, int iterations):
    """This follows the "Hi" algorithm specified in RFC5802"""
    return hashlib.pbkdf2_hmac(
        'sha256', password.encode('utf8'), base64.b64decode(salt), iterations
    )

Note the loop already hardcodes hashlib.sha256 (while the first hmac.new uses self.DIGEST), so the function is SHA-256-only as it stands — no digest agility is lost by naming 'sha256' explicitly. If DIGEST should ever become configurable, pbkdf2_hmac takes the algorithm name as its first argument, so the change doesn't stand in the way.

_bytes_xor would become unused unless it's referenced elsewhere.

Happy to open a PR if the direction looks right.

Related

#378 (Support using pre-hashed passwords) would sidestep the derivation entirely, which is a broader change; this one is a drop-in replacement with identical output.

Ngôn ngữ chính
Python
Star
8.1k
Fork
469
Merge trung bình
2 ngày 20 giờ
Pull request đã merge (30 ngày)
9

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của MagicStack/asyncpg

Tất cả issue của MagicStack/asyncpg

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.