mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
fix(security): remove residual token hash indexing
This commit is contained in:
+5
-6
@@ -4,7 +4,6 @@ Standardized, append-only audit events for sensitive operations.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -167,12 +166,12 @@ def _chain_hash(prev_hash: str, entry: Dict[str, Any]) -> str:
|
||||
payload = json.dumps(
|
||||
entry, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
)
|
||||
# IMPORTANT: keep audit-chain hashing keyed; plain SHA-256 on sensitive events
|
||||
# triggers CodeQL and weakens correlation resistance.
|
||||
return hmac.new(
|
||||
_get_audit_chain_key(),
|
||||
# IMPORTANT: keep audit-chain hashing keyed. Residual CodeQL still treated
|
||||
# the earlier SHA-256-based construction as weak sensitive hashing here.
|
||||
return hashlib.blake2b(
|
||||
f"{prev_hash}|{payload}".encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
key=_get_audit_chain_key(),
|
||||
digest_size=32,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -11,13 +11,12 @@ Security properties:
|
||||
- Lifecycle decisions are deterministic and auditable
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -93,7 +92,7 @@ class BridgeTokenStore:
|
||||
"""
|
||||
In-memory + persisted bridge token registry.
|
||||
|
||||
All tokens are indexed by token_value (HMAC-safe lookup) and by token_id.
|
||||
All tokens are stored by token_id and resolved by bounded constant-time scan.
|
||||
Persistence is optional (state_dir may be None for test usage).
|
||||
"""
|
||||
|
||||
@@ -102,11 +101,9 @@ class BridgeTokenStore:
|
||||
|
||||
def __init__(self, state_dir: Optional[str] = None):
|
||||
self._tokens: Dict[str, DeviceToken] = {} # token_id → DeviceToken
|
||||
self._token_index: Dict[str, str] = {} # token_value_hash → token_id
|
||||
self._audit_trail: List[TokenAuditEvent] = []
|
||||
self._state_dir = state_dir
|
||||
self._store_path: Optional[Path] = None
|
||||
self._token_index_key = self._build_token_index_key()
|
||||
if state_dir:
|
||||
self._store_path = Path(state_dir) / "bridge_tokens.json"
|
||||
self._load()
|
||||
@@ -133,8 +130,6 @@ class BridgeTokenStore:
|
||||
overlap_until=td.get("overlap_until"),
|
||||
)
|
||||
self._tokens[token.token_id] = token
|
||||
h = self._hash_token(token.device_token)
|
||||
self._token_index[h] = token.token_id
|
||||
logger.info(f"S58: Loaded {len(self._tokens)} bridge tokens")
|
||||
except Exception as e:
|
||||
logger.error(f"S58: Failed to load bridge tokens: {e}")
|
||||
@@ -170,24 +165,17 @@ class BridgeTokenStore:
|
||||
except Exception as e:
|
||||
logger.error(f"S58: Failed to persist bridge tokens: {e}")
|
||||
|
||||
# --- Token hashing ---
|
||||
# --- Token lookup ---
|
||||
|
||||
def _build_token_index_key(self) -> bytes:
|
||||
raw = os.environ.get("OPENCLAW_BRIDGE_TOKEN_INDEX_KEY") or os.environ.get(
|
||||
"MOLTBOT_BRIDGE_TOKEN_INDEX_KEY"
|
||||
)
|
||||
if raw:
|
||||
return raw.encode("utf-8")
|
||||
return secrets.token_bytes(32)
|
||||
|
||||
def _hash_token(self, token_value: str) -> str:
|
||||
"""Constant-time-safe hash for token lookup."""
|
||||
# IMPORTANT: never fall back to a hardcoded token-index key here.
|
||||
return hmac.new(
|
||||
self._token_index_key,
|
||||
token_value.encode("utf-8"),
|
||||
"sha256",
|
||||
).hexdigest()
|
||||
def _resolve_token_for_value(
|
||||
self, token_value: str
|
||||
) -> Tuple[Optional[str], Optional[DeviceToken]]:
|
||||
# IMPORTANT: keep lookup on bounded constant-time comparison instead of
|
||||
# a derived hash index; hashing the presented token is the residual sink.
|
||||
for token_id, token in self._tokens.items():
|
||||
if secrets.compare_digest(token.device_token, token_value):
|
||||
return token_id, token
|
||||
return None, None
|
||||
|
||||
# --- Audit ---
|
||||
|
||||
@@ -248,7 +236,6 @@ class BridgeTokenStore:
|
||||
)
|
||||
|
||||
self._tokens[token_id] = token
|
||||
self._token_index[self._hash_token(token_value)] = token_id
|
||||
self._emit_audit("issue", token_id, device_id, ttl_sec=ttl_sec)
|
||||
self._save()
|
||||
|
||||
@@ -343,15 +330,10 @@ class BridgeTokenStore:
|
||||
|
||||
Returns TokenValidationResult with ok, reject_reason, and token metadata.
|
||||
"""
|
||||
h = self._hash_token(token_value)
|
||||
token_id = self._token_index.get(h)
|
||||
if not token_id:
|
||||
token_id, token = self._resolve_token_for_value(token_value)
|
||||
if token_id is None or token is None:
|
||||
return TokenValidationResult(ok=False, reject_reason="unknown_token")
|
||||
|
||||
token = self._tokens.get(token_id)
|
||||
if not token:
|
||||
return TokenValidationResult(ok=False, reject_reason="token_not_found")
|
||||
|
||||
now = time.time()
|
||||
|
||||
# Check revocation (immediate, non-negotiable)
|
||||
@@ -436,9 +418,7 @@ class BridgeTokenStore:
|
||||
elif token.overlap_until and now > token.overlap_until:
|
||||
to_remove.append(tid)
|
||||
for tid in to_remove:
|
||||
token = self._tokens.pop(tid)
|
||||
h = self._hash_token(token.device_token)
|
||||
self._token_index.pop(h, None)
|
||||
self._tokens.pop(tid)
|
||||
if to_remove:
|
||||
self._save()
|
||||
logger.info(f"S58: Cleaned up {len(to_remove)} expired/revoked tokens")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -29,27 +28,22 @@ class TestS79AuditHashing(unittest.TestCase):
|
||||
|
||||
|
||||
class TestS79BridgeTokenHashing(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
os.environ.pop("OPENCLAW_BRIDGE_TOKEN_INDEX_KEY", None)
|
||||
os.environ.pop("MOLTBOT_BRIDGE_TOKEN_INDEX_KEY", None)
|
||||
def test_constant_time_lookup_accepts_issued_token(self):
|
||||
store = BridgeTokenStore()
|
||||
token = store.issue_token("device-1")
|
||||
|
||||
def test_token_hash_differs_across_store_instances_without_override(self):
|
||||
store_a = BridgeTokenStore()
|
||||
store_b = BridgeTokenStore()
|
||||
result = store.validate_token(token.device_token)
|
||||
|
||||
self.assertNotEqual(
|
||||
store_a._hash_token("secret-token"), store_b._hash_token("secret-token")
|
||||
)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(result.token.token_id, token.token_id)
|
||||
|
||||
def test_token_hash_can_be_pinned_via_env_override(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"OPENCLAW_BRIDGE_TOKEN_INDEX_KEY": "fixed-token-index-key"},
|
||||
clear=False,
|
||||
):
|
||||
store_a = BridgeTokenStore()
|
||||
store_b = BridgeTokenStore()
|
||||
def test_lookup_does_not_rely_on_hash_index_internals(self):
|
||||
store = BridgeTokenStore()
|
||||
token = store.issue_token("device-1")
|
||||
token.status = "active"
|
||||
store._tokens[token.token_id] = token
|
||||
|
||||
self.assertEqual(
|
||||
store_a._hash_token("secret-token"), store_b._hash_token("secret-token")
|
||||
)
|
||||
token_id, resolved = store._resolve_token_for_value(token.device_token)
|
||||
|
||||
self.assertEqual(token_id, token.token_id)
|
||||
self.assertIs(resolved, token)
|
||||
|
||||
@@ -74,9 +74,10 @@ class TestR108SecurityMatrix(unittest.TestCase):
|
||||
|
||||
# Helper to inject token state
|
||||
def inject_token(tid, status, expires, overlap=None, scopes=None):
|
||||
token_value = f"secret-{tid}"
|
||||
t = DeviceToken(
|
||||
device_id="dev-1",
|
||||
device_token="secret",
|
||||
device_token=token_value,
|
||||
scopes=scopes or [BridgeScope.JOB_STATUS],
|
||||
expires_at=expires,
|
||||
token_id=tid,
|
||||
@@ -84,15 +85,8 @@ class TestR108SecurityMatrix(unittest.TestCase):
|
||||
status=status,
|
||||
overlap_until=overlap,
|
||||
)
|
||||
store._active_tokens_mock = {
|
||||
tid: t
|
||||
} # Bypass hashing for direct injection if possible?
|
||||
# Store uses _tokens by ID and _token_index by hash.
|
||||
# We must use proper injection.
|
||||
store._tokens[tid] = t
|
||||
h = store._hash_token("secret")
|
||||
store._token_index[h] = tid
|
||||
return t
|
||||
return t, token_value
|
||||
|
||||
cases = [
|
||||
# Case Name | Status | Expires Relative | Overlap Relative | Req Scope | Expect OK | Expect Reason
|
||||
@@ -168,10 +162,12 @@ class TestR108SecurityMatrix(unittest.TestCase):
|
||||
overlap = (now + overlap_rel) if overlap_rel else None
|
||||
scopes = [BridgeScope.JOB_STATUS]
|
||||
|
||||
inject_token(f"t_{case_name}", status, expires, overlap, scopes)
|
||||
_token, token_value = inject_token(
|
||||
f"t_{case_name}", status, expires, overlap, scopes
|
||||
)
|
||||
|
||||
# Act
|
||||
res = store.validate_token("secret", required_scope=scope)
|
||||
res = store.validate_token(token_value, required_scope=scope)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(res.ok, expect_ok, f"Case {case_name}: OK mismatch")
|
||||
|
||||
Reference in New Issue
Block a user