From 9491cd27426b595d494519455a45ca87c1b99b1d Mon Sep 17 00:00:00 2001 From: yaojin Date: Wed, 5 Aug 2026 22:09:54 +0800 Subject: [PATCH] fix: prevent OpenClaw API key hash reuse --- backend/app/api/gateway.py | 16 ++--------- backend/app/schemas/schemas.py | 1 - backend/tests/test_gateway_runtime_a2a.py | 34 ++++++++++++++++++++++- frontend/src/pages/OpenClawSettings.tsx | 10 +++---- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 98079efe2..23e97cd25 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -42,28 +42,16 @@ def _hash_key(key: str) -> str: async def _get_agent_by_key(api_key: str, db: AsyncSession) -> Agent: """Authenticate an OpenClaw agent by its API key.""" - # First try plaintext (new behavior) + key_hash = _hash_key(api_key) result = await db.execute( select(Agent).where( - Agent.api_key_hash == api_key, + Agent.api_key_hash == key_hash, Agent.agent_type == "openclaw", Agent.deleted_at.is_(None), ) ) agent = result.scalar_one_or_none() - # Fallback to hashed (legacy behavior) - if not agent: - key_hash = _hash_key(api_key) - result = await db.execute( - select(Agent).where( - Agent.api_key_hash == key_hash, - Agent.agent_type == "openclaw", - Agent.deleted_at.is_(None), - ) - ) - agent = result.scalar_one_or_none() - if not agent: raise HTTPException(status_code=401, detail="Invalid API key") return agent diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 11b354a01..ee5605b29 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -287,7 +287,6 @@ class AgentOut(BaseModel): openclaw_last_seen: datetime | None = None unread_count: int = 0 has_api_key: bool = False - api_key_hash: str | None = None # True when the current viewer already has an onboarding row for this # agent. Computed per-request by the API layer from the junction table; # not an ORM attribute, so callers must set it explicitly. Defaults to diff --git a/backend/tests/test_gateway_runtime_a2a.py b/backend/tests/test_gateway_runtime_a2a.py index a2dbddd59..f7da0e7d1 100644 --- a/backend/tests/test_gateway_runtime_a2a.py +++ b/backend/tests/test_gateway_runtime_a2a.py @@ -13,7 +13,7 @@ from app.models.agent import Agent from app.models.audit import ChatMessage from app.models.gateway_message import GatewayMessage -from app.schemas.schemas import GatewayReportRequest, GatewaySendMessageRequest +from app.schemas.schemas import AgentOut, GatewayReportRequest, GatewaySendMessageRequest from app.services.agent_runtime.a2a_runtime import ( GatewayA2ARuntimeCompletion, GatewayA2ARuntimeIntake, @@ -45,10 +45,12 @@ def scalar_one_or_none(self): class _Session: def __init__(self, *results: object) -> None: self.results = deque(results) + self.statements: list[object] = [] self.commits = 0 self.rollbacks = 0 async def execute(self, _statement) -> _Result: + self.statements.append(_statement) value = self.results.popleft() return _Result([] if value is None else [value]) @@ -59,6 +61,36 @@ async def rollback(self) -> None: self.rollbacks += 1 +@pytest.mark.asyncio +async def test_gateway_authentication_hashes_presented_key_before_lookup() -> None: + agent = object() + db = _Session(agent) + + authenticated = await gateway._get_agent_by_key("oc-plaintext-key", db) + + assert authenticated is agent + assert len(db.statements) == 1 + params = db.statements[0].compile().params + assert gateway._hash_key("oc-plaintext-key") in params.values() + assert "oc-plaintext-key" not in params.values() + + +@pytest.mark.asyncio +async def test_gateway_rejects_stored_hash_as_presented_key() -> None: + stored_hash = gateway._hash_key("oc-plaintext-key") + db = _Session(None) + + with pytest.raises(gateway.HTTPException, match="Invalid API key") as exc_info: + await gateway._get_agent_by_key(stored_hash, db) + + assert exc_info.value.status_code == 401 + assert len(db.statements) == 1 + + +def test_agent_output_never_serializes_api_key_hash() -> None: + assert "api_key_hash" not in AgentOut.model_json_schema()["properties"] + + class _ReportSession: def __init__(self, *results: object) -> None: self.results = deque(results) diff --git a/frontend/src/pages/OpenClawSettings.tsx b/frontend/src/pages/OpenClawSettings.tsx index cd2e67685..86f6e9ca1 100644 --- a/frontend/src/pages/OpenClawSettings.tsx +++ b/frontend/src/pages/OpenClawSettings.tsx @@ -129,8 +129,8 @@ export default function OpenClawSettings({ agent, agentId, canManage }: OpenClaw {/* API Key Display Logic */} {(() => { - const activeKey = apiKey || (agent?.api_key_hash?.startsWith('oc-') ? agent.api_key_hash : null); - const isLegacyHash = hasKey && !activeKey; + const activeKey = apiKey; + const hasConfiguredKey = hasKey && !activeKey; if (activeKey) { return ( @@ -171,8 +171,8 @@ export default function OpenClawSettings({ agent, agentId, canManage }: OpenClaw fontFamily: 'monospace', fontSize: '13px', color: 'var(--text-secondary)', letterSpacing: '0.5px', }}> - {isLegacyHash - ? (isChinese ? '旧版密钥(已加密隐藏),请重新生成以查看明文' : 'Legacy key (encrypted), please regenerate to view') + {hasConfiguredKey + ? (isChinese ? '密钥已配置。为安全起见,无法再次显示明文。' : 'A key is configured. Its plaintext cannot be shown again for security.') : (isChinese ? '未生成' : 'Not generated')} {canManage && }