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:
k
2026-05-29 14:59:24 +08:00
parent 6876c0c280
commit 4c34abcd55
2 changed files with 33 additions and 11 deletions
+21 -6
View File
@@ -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
View File
@@ -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