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
+12 -5
View File
@@ -156,12 +156,19 @@ async def get_user(
trades = trades_result.scalars().all()
hl_api_key_set = bool(sub.hl_api_key)
# Never return the encrypted blob or any decrypted key. The masked hint
# shown to the user is derived lazily and only from the last 6 chars of
# the stored blob's sha-hash, not the plaintext.
# Masked hint MUST match what set_hl_api_key returned at save time, i.e.
# the last 6 chars of the real key ("...abc123"). Previously this hashed
# 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:
import hashlib
masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:]
try:
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:
masked = None