fix: sign /positions/hl reads, make HL key mask consistent across save/reload
- /positions/hl/{wallet} was unauthenticated — knowing a wallet address alone
exposed that wallet's live Hyperliquid positions + already_adopted status.
Its docstring claimed "same trust model as /positions/open", but that route
IS signed. Now requires the same view signature (view_positions/view_user,
allow_replay). The Telegram /adopt path calls list_hl_positions() directly,
so it's unaffected.
- HL API key masked hint was inconsistent: set_hl_api_key returns the real
key's last 6 chars ("...abc123"), but get_user returned 6 chars of
sha256(encrypted_blob) — a different string. After a reload the suffix
changed, making users think their key was altered. get_user now decrypts
only to take the real last 6 chars (matching save), with a neutral
"…(set)" fallback if decryption fails.
72 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+21
-6
@@ -427,21 +427,36 @@ class HLPositionsResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/positions/hl/{wallet}", response_model=HLPositionsResponse)
|
@router.get("/positions/hl/{wallet}", response_model=HLPositionsResponse)
|
||||||
async def list_hl_positions_endpoint(wallet: str):
|
async def list_hl_positions_endpoint(
|
||||||
|
wallet: str,
|
||||||
|
ts: int = Query(..., description="Signed timestamp (ms)"),
|
||||||
|
sig: str = Query(..., description="EIP-191 signature"),
|
||||||
|
):
|
||||||
"""Read the wallet's CURRENT Hyperliquid open positions, annotated with
|
"""Read the wallet's CURRENT Hyperliquid open positions, annotated with
|
||||||
'already adopted' flag. Used by the Adopt picker on the frontend and by
|
'already adopted' flag. Used by the Adopt picker on the frontend.
|
||||||
the Telegram /adopt command.
|
|
||||||
|
|
||||||
No auth — same trust model as /positions/open (wallet address IS the
|
Signed read — a wallet's live HL positions + adoption status are private,
|
||||||
access key). Reading HL state is read-only and harmless.
|
so this requires the SAME view signature as /positions/open (knowing a
|
||||||
|
wallet address alone must NOT expose its positions). The Telegram /adopt
|
||||||
|
command calls the `list_hl_positions` service function directly and is
|
||||||
|
unaffected by this HTTP-layer check.
|
||||||
"""
|
"""
|
||||||
|
wallet = wallet.lower().strip()
|
||||||
|
verify_signed_request_any(
|
||||||
|
actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER],
|
||||||
|
wallet=wallet,
|
||||||
|
timestamp_ms=ts,
|
||||||
|
signature=sig,
|
||||||
|
body=None,
|
||||||
|
allow_replay=True,
|
||||||
|
)
|
||||||
from app.services.adoption import list_hl_positions, AdoptionError
|
from app.services.adoption import list_hl_positions, AdoptionError
|
||||||
try:
|
try:
|
||||||
items = await list_hl_positions(wallet)
|
items = await list_hl_positions(wallet)
|
||||||
except AdoptionError as exc:
|
except AdoptionError as exc:
|
||||||
raise HTTPException(400, exc.message)
|
raise HTTPException(400, exc.message)
|
||||||
return HLPositionsResponse(
|
return HLPositionsResponse(
|
||||||
wallet=wallet.lower().strip(),
|
wallet=wallet,
|
||||||
count=len(items),
|
count=len(items),
|
||||||
positions=[HLPositionItem(**vars(it)) for it in items],
|
positions=[HLPositionItem(**vars(it)) for it in items],
|
||||||
)
|
)
|
||||||
|
|||||||
+12
-5
@@ -156,12 +156,19 @@ async def get_user(
|
|||||||
trades = trades_result.scalars().all()
|
trades = trades_result.scalars().all()
|
||||||
|
|
||||||
hl_api_key_set = bool(sub.hl_api_key)
|
hl_api_key_set = bool(sub.hl_api_key)
|
||||||
# Never return the encrypted blob or any decrypted key. The masked hint
|
# Masked hint MUST match what set_hl_api_key returned at save time, i.e.
|
||||||
# shown to the user is derived lazily and only from the last 6 chars of
|
# the last 6 chars of the real key ("...abc123"). Previously this hashed
|
||||||
# the stored blob's sha-hash, not the plaintext.
|
# the encrypted blob instead, so the suffix shown after a reload differed
|
||||||
|
# from the one shown right after saving — users thought their key changed.
|
||||||
|
# We decrypt only to take the last 6 chars (never return the full key).
|
||||||
if hl_api_key_set:
|
if hl_api_key_set:
|
||||||
import hashlib
|
try:
|
||||||
masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:]
|
from app.services.crypto import decrypt_api_key
|
||||||
|
masked = f"...{decrypt_api_key(sub.hl_api_key)[-6:]}"
|
||||||
|
except Exception:
|
||||||
|
# Decryption failed (rotated KEK, corrupt blob) — don't leak the
|
||||||
|
# ciphertext or crash settings load; show a neutral "set" marker.
|
||||||
|
masked = "…(set)"
|
||||||
else:
|
else:
|
||||||
masked = None
|
masked = None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user