Repository navigation
fix(security): Hash passwords with scrypt instead of SHA-256 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
services/platform/gateway/oneerp_gateway_app/services/password_hasher.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """비밀번호 해시 — scrypt + 무작위 salt. | ||
|
|
||
| 저장 형식: ``scrypt$<n>$<r>$<p>$<salt hex>$<key hex>`` | ||
| 예: ``scrypt$16384$8$1$9f0c…$4be1…`` | ||
|
|
||
| 레거시 SHA-256 hex 해시는 검증만 지원하고, 로그인 성공 시 scrypt 로 교체한다 | ||
| (``needs_rehash``). 새 해시는 언제나 scrypt 다. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import hmac | ||
| import secrets | ||
|
|
||
| _SCHEME = "scrypt" | ||
| _SEP = "$" | ||
| _SCRYPT_N = 2**14 | ||
| _SCRYPT_R = 8 | ||
| _SCRYPT_P = 1 | ||
| _SALT_BYTES = 16 | ||
| _KEY_BYTES = 32 | ||
| _FIELD_COUNT = 6 | ||
| _LEGACY_HEX_LEN = 64 | ||
|
|
||
|
|
||
| def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes: | ||
| return hashlib.scrypt(password.encode(), salt=salt, n=n, r=r, p=p, dklen=_KEY_BYTES) | ||
|
|
||
|
|
||
| def hash_password(password: str) -> str: | ||
| """비밀번호를 scrypt 로 해시한다.""" | ||
| salt = secrets.token_bytes(_SALT_BYTES) | ||
| key = _derive(password, salt, _SCRYPT_N, _SCRYPT_R, _SCRYPT_P) | ||
| fields = [_SCHEME, str(_SCRYPT_N), str(_SCRYPT_R), str(_SCRYPT_P), salt.hex(), key.hex()] | ||
| return _SEP.join(fields) | ||
|
|
||
|
|
||
| def _verify_scrypt(password: str, stored: str) -> bool: | ||
| fields = stored.split(_SEP) | ||
| if len(fields) != _FIELD_COUNT: | ||
| return False | ||
|
|
||
| _, n, r, p, salt_hex, key_hex = fields | ||
| try: | ||
| key = _derive(password, bytes.fromhex(salt_hex), int(n), int(r), int(p)) | ||
| expected = bytes.fromhex(key_hex) | ||
| except ValueError: | ||
| return False | ||
| return hmac.compare_digest(key, expected) | ||
|
|
||
|
|
||
| def _verify_legacy(password: str, stored: str) -> bool: | ||
| # 마이그레이션 전용 — 기존 SHA-256 저장분을 확인해 scrypt 로 올리기 위해서만 쓴다. | ||
| digest = hashlib.sha256(password.encode()).hexdigest() | ||
| return hmac.compare_digest(digest, stored) | ||
|
|
||
|
|
||
| def verify_password(password: str, stored: str) -> bool: | ||
| """저장된 해시(scrypt 또는 레거시 SHA-256)와 비밀번호를 상수 시간 비교한다.""" | ||
| if stored.startswith(_SCHEME + _SEP): | ||
| return _verify_scrypt(password, stored) | ||
| if len(stored) == _LEGACY_HEX_LEN: | ||
| return _verify_legacy(password, stored) | ||
| return False | ||
|
|
||
|
|
||
| def needs_rehash(stored: str) -> bool: | ||
| """현재 scrypt 파라미터가 아닌 해시면 True — 로그인 성공 시 교체 대상.""" | ||
| prefix = _SEP.join([_SCHEME, str(_SCRYPT_N), str(_SCRYPT_R), str(_SCRYPT_P)]) + _SEP | ||
| return not stored.startswith(prefix) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
services/platform/gateway/tests/unit/test_password_hasher.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| """password_hasher 단위 테스트.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from oneerp_gateway_app.services.password_hasher import ( | ||
| hash_password, | ||
| needs_rehash, | ||
| verify_password, | ||
| ) | ||
|
|
||
| _PLAIN = "secret" | ||
| # 레거시 SHA-256("secret") — 마이그레이션 전 저장분 형식. | ||
| _LEGACY = "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b" | ||
|
|
||
|
|
||
| def test_scrypt_해시는_검증되고_재해시가_불필요하다() -> None: | ||
| stored = hash_password(_PLAIN) | ||
|
|
||
| assert stored.startswith("scrypt$") | ||
| assert verify_password(_PLAIN, stored) | ||
| assert not verify_password("wrong", stored) | ||
| assert not needs_rehash(stored) | ||
|
|
||
|
|
||
| def test_같은_비밀번호도_salt로_해시가_다르다() -> None: | ||
| assert hash_password(_PLAIN) != hash_password(_PLAIN) | ||
|
|
||
|
|
||
| def test_레거시_해시는_검증되고_재해시_대상이다() -> None: | ||
| assert verify_password(_PLAIN, _LEGACY) | ||
| assert not verify_password("wrong", _LEGACY) | ||
| assert needs_rehash(_LEGACY) | ||
|
|
||
|
|
||
| def test_형식이_깨진_해시는_거부한다() -> None: | ||
| assert not verify_password(_PLAIN, "") | ||
| assert not verify_password(_PLAIN, "scrypt$bad") | ||
| assert not verify_password(_PLAIN, "scrypt$16384$8$1$zz$zz") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.