forked from PastaPastaPasta/dash-faucet
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add trusted bearer-key faucet API #2
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
Open
infraclaw-dash
wants to merge
1
commit into
dashpay:main
Choose a base branch
from
infraclaw-dash:infraclaw/api-key-faucet
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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
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
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,57 @@ | ||
| """Authentication helpers for trusted faucet API clients.""" | ||
|
|
||
| import hashlib | ||
| import hmac | ||
| import json | ||
|
|
||
| from app.config import settings | ||
|
|
||
|
|
||
| def parse_api_key_hashes(value: str) -> dict[str, str]: | ||
| """Parse a JSON mapping of API key IDs to SHA-256 hashes.""" | ||
| if not value.strip(): | ||
| return {} | ||
|
|
||
| parsed = json.loads(value) | ||
| if not isinstance(parsed, dict): | ||
| raise ValueError("FAUCET_API_KEY_HASHES must be a JSON object") | ||
|
|
||
| result: dict[str, str] = {} | ||
| for key_id, key_hash in parsed.items(): | ||
| if not isinstance(key_id, str) or not key_id.strip(): | ||
| raise ValueError("Faucet API key IDs must be non-empty strings") | ||
| if ( | ||
| not isinstance(key_hash, str) | ||
| or len(key_hash) != 64 | ||
| or any(char not in "0123456789abcdefABCDEF" for char in key_hash) | ||
| ): | ||
| raise ValueError( | ||
| f"Faucet API key hash for {key_id!r} must be a SHA-256 hex digest" | ||
| ) | ||
| result[key_id] = key_hash.lower() | ||
|
|
||
| return result | ||
|
|
||
|
|
||
| API_KEY_HASHES = parse_api_key_hashes(settings.faucet_api_key_hashes) | ||
|
|
||
|
|
||
| def authenticate_api_key(authorization: str | None) -> str | None: | ||
| """Return the matching key ID for a valid Bearer token.""" | ||
| if not authorization: | ||
| return None | ||
|
|
||
| scheme, separator, token = authorization.partition(" ") | ||
| token = token.strip() | ||
| if not separator or scheme.casefold() != "bearer" or not token: | ||
| return None | ||
|
|
||
| presented_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() | ||
| matched_key_id = None | ||
|
|
||
| # Compare against every configured hash to avoid revealing which key IDs exist. | ||
| for key_id, expected_hash in API_KEY_HASHES.items(): | ||
| if hmac.compare_digest(presented_hash, expected_hash): | ||
| matched_key_id = key_id | ||
|
|
||
| return matched_key_id |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| """Tests for trusted faucet API key authentication.""" | ||
|
|
||
| import hashlib | ||
| import unittest | ||
| from unittest.mock import patch | ||
|
|
||
| from app.services import api_keys | ||
|
|
||
|
|
||
| class ApiKeyTests(unittest.TestCase): | ||
| def test_parse_api_key_hashes(self): | ||
| digest = hashlib.sha256(b"secret").hexdigest() | ||
| self.assertEqual( | ||
| api_keys.parse_api_key_hashes(f'{{"platform-team":"{digest}"}}'), | ||
| {"platform-team": digest}, | ||
| ) | ||
|
|
||
| def test_parse_rejects_non_sha256_hash(self): | ||
| with self.assertRaises(ValueError): | ||
| api_keys.parse_api_key_hashes('{"platform-team":"short"}') | ||
|
|
||
| def test_authenticate_valid_bearer_key(self): | ||
| token = "test-api-key" | ||
| digest = hashlib.sha256(token.encode()).hexdigest() | ||
| with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}): | ||
| self.assertEqual( | ||
| api_keys.authenticate_api_key(f"Bearer {token}"), | ||
| "platform-team", | ||
| ) | ||
|
|
||
| def test_authenticate_rejects_invalid_or_missing_key(self): | ||
| digest = hashlib.sha256(b"valid-key").hexdigest() | ||
| with patch.object(api_keys, "API_KEY_HASHES", {"platform-team": digest}): | ||
| self.assertIsNone(api_keys.authenticate_api_key(None)) | ||
| self.assertIsNone(api_keys.authenticate_api_key("Basic abc")) | ||
| self.assertIsNone(api_keys.authenticate_api_key("Bearer wrong-key")) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
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,63 @@ | ||
| """Tests for the trusted server-to-server faucet endpoint.""" | ||
|
|
||
| import unittest | ||
| from unittest.mock import Mock, patch | ||
|
|
||
| from fastapi import HTTPException | ||
|
|
||
| from app.routers import faucet | ||
|
|
||
|
|
||
| class TrustedFaucetTests(unittest.IsolatedAsyncioTestCase): | ||
| async def test_rejects_invalid_key_before_payout(self): | ||
| body = faucet.ApiCoreFaucetRequest(address="y" * 34) | ||
| with patch.object(faucet, "authenticate_api_key", return_value=None), patch.object( | ||
| faucet, "dispense_core_dash" | ||
| ) as dispense: | ||
| with self.assertRaises(HTTPException) as raised: | ||
| await faucet.api_core_faucet(body, "Bearer invalid") | ||
|
|
||
| self.assertEqual(raised.exception.status_code, 401) | ||
| dispense.assert_not_called() | ||
|
|
||
| async def test_valid_key_bypasses_public_protection_and_records_usage(self): | ||
| body = faucet.ApiCoreFaucetRequest(address="y" * 34) | ||
| expected = faucet.CoreFaucetResponse( | ||
| txid="ab" * 32, | ||
| amount=1.0, | ||
| address=body.address, | ||
| ) | ||
| limiter = Mock() | ||
| limiter.is_allowed.return_value = (True, 0) | ||
|
|
||
| with patch.object( | ||
| faucet, "authenticate_api_key", return_value="platform-team" | ||
| ), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object( | ||
| faucet, "dispense_core_dash", return_value=expected | ||
| ) as dispense: | ||
| result = await faucet.api_core_faucet(body, "Bearer valid") | ||
|
|
||
| self.assertEqual(result, expected) | ||
| dispense.assert_called_once_with(body.address, faucet.settings.core_faucet_amount) | ||
| limiter.record_request.assert_called_once_with("platform-team") | ||
|
|
||
| async def test_daily_limit_blocks_before_payout(self): | ||
| body = faucet.ApiCoreFaucetRequest(address="y" * 34) | ||
| limiter = Mock() | ||
| limiter.is_allowed.return_value = (False, 123) | ||
|
|
||
| with patch.object( | ||
| faucet, "authenticate_api_key", return_value="platform-team" | ||
| ), patch.object(faucet, "api_key_rate_limiter", limiter), patch.object( | ||
| faucet, "dispense_core_dash" | ||
| ) as dispense: | ||
| with self.assertRaises(HTTPException) as raised: | ||
| await faucet.api_core_faucet(body, "Bearer valid") | ||
|
|
||
| self.assertEqual(raised.exception.status_code, 429) | ||
| self.assertEqual(raised.exception.headers["Retry-After"], "123") | ||
| dispense.assert_not_called() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Daily-limit check-then-act race across the payout RPC call.
api_key_rate_limiter.is_allowed(key_id)andrecord_request(key_id)are separate locked operations withdispense_core_dash's network call to Dash Core RPC executed in between. Concurrent requests using the same API key can all pass the check before any of them records, letting a key exceed the intended daily safety cap.🔒 Suggested direction: make check+reserve atomic
(
try_reserve/releasewould need to be added toRateLimiter, performing the check-and-append under one lock acquisition.)🤖 Prompt for AI Agents